Skip to main content
Version: Current (v6.11.1)

7.x Upgrade Guide

Breaking changes​

Reading Form internals through a ref​

Form is now a function component. Its ref exposes FormRef<T> instead of a class instance, and FormState, the class component's state type, is no longer exported: IChangeEvent is the public shape of what reaches your callbacks.

First, change the ref's type. Use your form-data type in place of MyData:

-import Form from '@rjsf/core';
+import Form, { type FormRef } from '@rjsf/core';

-const formRef = useRef<Form<MyData>>(null);
+const formRef = useRef<FormRef<MyData>>(null);

The same change applies to createRef. Then replace any access to class internals:

If your code uses…Change it to…
formRef.current.state.formDataformRef.current.getFormData()
.state.errorSchema or .state.errorsRead event.errorSchema or event.errors in onChange/onSubmit.
.setState(...)Call .setFieldValue(path, value), or .setFieldValue('', data) to replace the whole value.
.state.schemaUtilsUse registry.schemaUtils in a custom field, or create one with createSchemaUtils({ validator }, schema).
.state.registryUse the registry prop in a custom field, widget or template. Outside Form, use buildRegistry(props, schemaUtils.getRootSchema(), schemaUtils).
.formElementUse .reset() if you need to reset the form. The handle does not expose the DOM element.

For example, save the data using the handle and keep error information from events:

-const data = formRef.current.state.formData;
+const data = formRef.current.getFormData();

-const errors = formRef.current.state.errors;
+const onChange = (event) => setErrors(event.errors);

submit(), setFieldValue(), validateForm(), validateFormWithFormData() and focusOnError() keep their existing signatures. reset() takes an optional seed, see reset() goes back to the seed. For the separate change to validate(), see the validate migration.

Form owns its data BREAKING CHANGE​

This is the change most forms will notice. Read the model first. Then use the table to find the cases that apply to your code.

The model​

React has two kinds of input. A controlled <input value={text}> shows exactly what its parent passes, and changes only when the parent passes something new. An uncontrolled <input defaultValue={text}> reads the prop once and then keeps its own value. The parent hears about edits through onChange and reads the current value when it needs it.

Form is the uncontrolled kind:

  • initialFormData is the seed, like defaultValue. The form reads it when it mounts and when reset() is called, and fills it in with the schema's defaults. On every other render the form ignores it.
  • After mount, the data belongs to the form. Each edit is applied, validated and shown before onChange reports it, whatever your handler does with the event.
  • getFormData() reads the current value at any time, from anywhere you hold the form's ref.
  • formData is a deprecated name for the seed. It still works, so that forms written for v6 keep compiling and, for the common patterns, keep working, and when you pass both props, formData is the seed. Its @deprecated tag flags it in your editor and linter: rename it to initialFormData. React's docs use the same convention: a prop that is read once should start with initial or default, so readers know its later values are ignored.

Only undefined means "not passed". null seeds the schema's defaults, as undefined does. false, 0 and '' are values.

What to change​

If your code…Then…
Stores every edit: formData={data} onChange={(e) => setData(e.formData)}Change nothing. The value you pass back is the form's own, so passing it changes nothing, even if you store it late (after an await, say).
Passes formData at allRename it to initialFormData, which reads the same. formData is deprecated.
Passes a formData that changes after mount: a record that loads, the next record, an outside editRemount the form under a key, or call setFieldValue().
Refuses an edit by not storing it, or stores a rewritten copy, such as a trimmed stringMove that decision into transformChange.
Computes a field from other fields and passes it back as formDataCompute it in transformChange, and into initialFormData.
Writes formData inline, such as formData={{}}, and doesn't store what onChange reportsChange nothing. A re-render no longer throws the user's edits away.
Expects onChange on mount: for the defaults, or for a type: 'null' field's nullRead getFormData().
Calls reset()Check which seed it goes back to.
Reads the DOM inside onChangeRead the event.
Edits a field and calls submit() in the same handlerMind the browser's validation.
Makes several edits that should be validated togetherMake them as one edit.
Renders the form inside <Activity>A hidden form is treated as unmounted.
Has a custom field that hands ArrayField or ObjectField a sorted or filtered copyPass formData through unchanged.
Relies on an error boundary to catch an onChange that throwsHandle the error in the callback.
Wraps a FormRef call in startTransition()Remove the wrapper. The call is synchronous either way.

Show other data after mount​

A formData that changes after mount no longer replaces the data. In development the form logs a warning the first time it ignores one. There are two ways to show other data. Which one to use depends on whether it is a different record or the same one.

A different record: remount the form under a key. This is how React recommends resetting all state when a prop changes. The new form starts clean: its errors and the fields' own state are cleared, and the record gets the values the form fills in on mount only, such as a ui:initialValue or the defaults of additional properties.

-<Form schema={schema} validator={validator} formData={record} onChange={save} />
+<Form key={record.id} schema={schema} validator={validator} initialFormData={record} onChange={save} />

For a record that loads after mount, render the form once the record is there:

if (!record) {
return <Spinner />;
}
return <Form key={record.id} schema={schema} validator={validator} initialFormData={record} onChange={save} />;

The same record, changed from outside: call setFieldValue() on the form's ref. setFieldValue('', data) replaces the whole value, and setFieldValue(path, value) replaces one field. The errors and the fields' state stay, and the change goes through transformChange and is reported through onChange, like any edit. Make the call where the new value arrives, such as the handler of a button or the callback of a request. A useEffect that watches a prop and copies it into the form adds a render and a step for no gain (You Might Not Need an Effect).

Refuse or rewrite an edit in transformChange​

By the time onChange runs, the form has already applied the edit, so not storing the event no longer refuses it. onChange reports an edit; it does not decide one. That decision goes in transformChange. This is a pure function that receives the data the edit produced (next) and the data before it (previous), and returns the data the form should keep. The form calls it for every edit, from a field or from setFieldValue(), and for those alone, before it stores, validates and reports the result. What the form derives on its own (its defaults, the live omit a blur runs and a reset()) is stored without it, and only the last two are reported through onChange. The defaults are filled in on mount and again when the schema, a ui:emptyValue, defaultFormStateBehavior or additionalPropertiesWhenAbsent changes. An onChange that refused or rewrote an edit becomes:

-const onChange = ({ formData }) => {
- if ((formData.title ?? '').length > 80) return;
- setData({ ...formData, code: formData.code?.toUpperCase() });
-};
+const transformChange = (next, previous) => {
+ if ((next?.title ?? '').length > 80) return previous;
+ return next?.code === undefined ? next : { ...next, code: next.code.toUpperCase() };
+};
+const onChange = ({ formData }) => setData(formData);

Return next to take the edit, previous to refuse it, or another value. It runs for every keystroke, so a rewrite has to hold for a value the user is still typing, and for a field they cleared, which is undefined: upper-casing does, while trimming would remove a space as soon as it is typed. Return next's unchanged subtrees as they are, so their fields don't render again. Keep the function pure: the form may call it more than once for the same edit, the way React may call a component more than once. If you have used Downshift's stateReducer or React Final Form's parse, this is the same idea applied to the whole form. A decision that has to wait, such as a server check, can still undo an edit afterwards with setFieldValue(). When an edit switches a oneOf/anyOf option, the selector stays on the option the user picked unless you refuse the switch or rewrite that field's data; a rewrite elsewhere in the form, such as stamping another property, keeps the choice, while one at the field keeps it only while the rewritten data still fits the picked option, and otherwise moves the selector to the option that data fits.

Defaults are filled in without onChange​

The form fills its seed in with the schema's defaults on mount, and again when the schema, a ui:emptyValue, defaultFormStateBehavior or additionalPropertiesWhenAbsent changes. That filling-in is something the form works out from its props, not an edit, so it no longer calls onChange. v6 called onChange for those defaults from its constructor, and the field of a type: 'null' schema called onChange(null) from an Effect as it mounted. Both were ways of telling the parent about state from outside the event that caused it, which React advises against. A missing type: 'null' value is now filled in as null like any other default, and whatever emptyObjectFields says, as the field used to, so the submitted data is unchanged wherever that field rendered. Where it didn't, the null is filled in too, as a default or a { "const": null } already was: a type: 'null' property left out of a LayoutGridField layout, or under a ui:field that doesn't render it, is now submitted as null where it was absent. The one other difference is an object holding such a field that you set to undefined, with setFieldValue() or transformChange: the field put its null back, re-creating the object with its defaults, where the form now leaves it undefined, as it leaves an object holding no such field.

If you keep a copy of the data from onChange, that copy has no defaults until the first edit. Read getFormData() when you need the value with its defaults, or take it from the onSubmit event.

If you call getDefaultFormState() yourself, it now fills in a missing type: 'null' value as null too (except inside an anyOf/oneOf option that is rendered), and its return type includes null.

setFieldValue() fills in the defaults of the value it writes​

Under mergeDefaultsIntoFormData: 'useDefaultIfFormDataUndefined', setFieldValue(path, undefined) at a field with a default left the field empty, while setFieldValue('', data) filled the same field's default back in. Both now fill it in, as the form does for any data it holds. The fields the user cleared elsewhere in the form stay empty, and only those: an undefined that setFieldValue() or the seed put there also takes a default that arrives later, such as one a new schema prop gives an object holding the field. A form that has to leave a field empty from code can keep mergeDefaultsIntoFormData at its default, 'useFormDataIfPresent', under which a field set to undefined stays undefined.

A cleared field gives way or stays as its field does​

Two cases of a field the user cleared behave differently now, and neither can be turned off:

  • When a field's type, const, enum (or the constants of its anyOf/oneOf), maxItems or default changes, through an if/then, an option switch or a new schema prop, a clear of it gives way to the new field's default, under every mergeDefaultsIntoFormData, where the field stayed empty. A disabled or readonly form keeps and gives way to its clears the same way, and so does any other key holding undefined, as in an enabled form. It leaves every value as it is, a null item the user didn't clear included.
  • Under mergeDefaultsIntoFormData: 'useDefaultIfFormDataUndefined', an anyOf/oneOf field of constants with a default stays cleared, as any other field does, where its default came back.

If your form relied on the old result, restore it from transformChange, which gets the data an edit settled on: write undefined back for a field that has to stay empty, or the default for one that has to take it again. A new schema prop runs no transformChange, so a field that has to stay empty through one needs its data set after the change.

A transformChange that drops a cleared key loses the clear under mergeDefaultsIntoFormData: 'useDefaultIfFormDataUndefined': a clear holds only while the key is there holding undefined, where a missing key used to count as cleared too. Pass the undefined through to keep it:

-const transformChange = ({ code, ...rest }) => (code ? { ...rest, code: code.toUpperCase() } : rest);
+const transformChange = (next) => (next.code === undefined ? next : { ...next, code: next.code.toUpperCase() });

The same goes for a copy made with JSON.parse(JSON.stringify(next)), which drops every undefined; copy with structuredClone().

reset() goes back to the seed​

reset() with no argument works as in v6, until you first pass it a seed. It reads the seed from the current props (formData if you pass it, otherwise initialFormData) and fills it in with the defaults. It clears the submit count and every error except extraErrors, and reports the result through onChange.

reset(saved) makes saved the seed instead, for this and every later reset(), which then goes back to saved rather than the props' seed, even after the props pass another one; remount the form under a key to read the props' seed again (see Making the saved data the baseline). So reset passed as an event handler now receives the event as its seed: see FormRef.reset() passed as an event handler seeds the form with the event.

Mind which seed you pass. If you pass formData={data} and store every edit in data, the seed is the latest edit, so reset() clears the errors and leaves the data as it is. To reset to the original record, pass the record as initialFormData and keep your copy of the edits in a separate state. Or remount the form under a key.

Read the event, not the DOM​

onChange now runs before the inputs show the new value. Take the value from the event:

const onChange = (event) => {
- const title = document.querySelector('#root_title').value;
- saveDraft({ title });
+ saveDraft(event.formData);
};

Outside onChange, call getFormData(). It returns the form's current value, including edits made earlier in the same tick. Code that needs the updated DOM itself belongs in an Effect.

Submit in the same handler as an edit​

submit() submits the form's current value, including an edit made in the same handler, and calls onSubmit or onError before it returns:

formRef.current?.setFieldValue('status', 'final');
formRef.current?.submit(); // submits { status: 'final' }

There is one limit. The browser's own constraint validation (required, pattern and the rest on the inputs) checks the DOM, and React has yet to show such an edit in the inputs. So a submit in the same handler as an edit is checked by schema validation alone. A submit made while the inputs show the current value, such as a submit button's, is checked by both. To check a value without storing or submitting it, call validateFormWithFormData(nextData).

Make several edits as one​

Live validation and liveOmit now run for every edit, where the v6 class validated a run of queued edits once. Several edits in one event still build on each other: each one reads the form's current value, so a second setFieldValue() keeps the first one's change. When the edits are one operation, replace the whole value in one call, so they are validated together:

-formRef.current.setFieldValue('city', 'Paris');
-formRef.current.setFieldValue('country', 'France');
+const data = formRef.current.getFormData();
+formRef.current.setFieldValue('', { ...data, city: 'Paris', country: 'France' });

A form inside a hidden <Activity>​

React asks components to treat content hidden by <Activity> as unmounted: it cleans up the hidden content's Effects and expects nothing in there to keep acting. Form does what React asks. While hidden, it keeps its data, but drops anything that reaches it: a draft a widget flushes from an Effect cleanup, a timer or a file read that finishes late, and a setFieldValue(), reset() or submit() through its ref. A validateForm() returns its result without calling onError. In development, the form warns once about the first setFieldValue(), reset() or submit() it drops this way, through a handle you kept: React clears the ref while the form is hidden, so ref.current is null then, and ref.current?.setFieldValue() skips the call without reaching the form. Everything works again once the form is shown. A form shown again by an <Activity> gets its ref back after its fields' layout Effects have run, so an onChange a widget reports from one finds ref.current still null. React also cleans up the Effects of content a <Suspense> fallback replaces, after it has been shown, so a form inside one drops the same things while the fallback shows. A form that has unmounted drops the same things, as in v6. Otherwise, a late change would be reported through a stale onChange and stored as the data of a form the user can't see.

Custom fields that wrap ArrayField or ObjectField​

ArrayField and ObjectField add, remove, rename and reorder by reading the form's current value at their fieldPath, so that several edits in one event build on each other. A custom field that wraps one of them has to pass formData through unchanged. If it passes a transformed copy, such as a sorted list, those operations act on positions the form doesn't hold, and in development the field warns once that it was rendered with other formData than the form holds at its fieldPath. The same goes for a field that hands it part of its own data under its own fieldPath, as <ArrayField {...props} formData={props.formData.items} /> does: the operations read the whole value at that path, so an add can replace it with an array of one new item and a remove does nothing. Render the part at its own fieldPath, or render the container for that part through SchemaField.

Handle callback errors in the callback​

An error thrown by your onChange, onBlur or onFocus no longer unmounts the form or reaches an error boundary. One thrown during a FormRef call comes out of that call. One caused by the user editing a field is reported as an uncaught error. If you need to act on the error, catch it inside the callback.

Changes and submits are validated as before 6.11.1​

6.11.1 changed how Form applies a change (#5631), and v7 doesn't keep all of it. If you upgrade from 6.11.1, check these:

  • A throw from customValidate or transformErrors during setFieldValue() comes out of setFieldValue(), as it did before 6.11.1, not from a timeout. A try/catch around the call catches it.
  • A throw from focusOnFirstError on submit comes out of the submit handler before onError is called, so onError isn't called for that submit. Catch the error inside focusOnFirstError if you need onError.
  • A change made in the same event as the parent setting schema, liveValidate or extraErrors is applied and validated with the props from before that event, as before 6.11.1. To apply it with the new props, make the change after they have rendered, such as from an effect.
  • noValidate doesn't hide a custom error a widget raised; it shows at once. To keep it hidden while validation is off, don't raise it then: pass your noValidate flag to the widget, for example through formContext, and call onChange without an errorSchema while it's set.

Also note​

  • With omitExtraData, a submit both passes the data with its extra fields removed to onSubmit and keeps it as the form's data.
  • Without live validation, an edit clears the validator's errors of the fields it changed. v6 did this only for a form passed formData; it now holds for every form. The errors that an item reorder, a property rename or an option switch moved stay with the values they moved with.
  • In development, the formData passed to onChange is deep-frozen, so mutating it throws instead of silently corrupting your state. Only plain objects and arrays are frozen. The object you seed the form with, initialFormData or formData, is frozen too from the form's first edit, validation, submit or reset on, since the form's data shares it: all of it, except the objects and arrays the form filled a default into, which it copied. v6 code that goes on mutating that object, such as this.state.data.address.city = 'x', throws in development; change a copy instead.

Why​

v6 looked controlled, but it wasn't. The form kept its own copy of the data, showed every edit whether or not you stored it, and re-read formData whenever any prop changed. Several long-standing bugs came from reconciling those two copies. An edit you didn't store stayed on screen until an unrelated prop changed, then snapped back. A oneOf switch could revert. Every prop change re-ran default generation.

There were two ways to get to one copy. One was to make the form truly controlled. That was tried during v7's development and dropped. A form holds state its data can't express, such as the selected oneOf option, which array row is which, a property name halfway through a rename, and each field's errors. Keeping all of that consistent while the parent decides about each edit took a lot of machinery, and a list of rules for you to follow. The other way was to let the form own its data, the way an uncontrolled input does. That is what React Hook Form (defaultValues), Formik (initialValues), React Final Form (initialValues) and TanStack Form (defaultValues) do too. A key starts it over, as React recommends.

ESM-only packages​

Every @rjsf/* package now ships only ESM: per-file modules and declarations in lib/. The CommonJS (dist/index.cjs), bundled ESM (dist/<name>.esm.js) and UMD (dist/<name>.umd.js) builds are gone, along with the ./dist and ./dist/*.cjs export paths.

  • import from any bundler or from Node works as before; require() also keeps working on every supported Node version, which can load ESM packages natively.
  • Deep imports through @rjsf/<package>/lib/<file>.js no longer resolve: see deep imports are removed.
  • If you loaded a UMD bundle from a CDN with a <script> tag, switch to an ESM CDN (for example import Form from 'https://esm.sh/@rjsf/core') or a bundler.

Deep imports are removed BREAKING CHANGE​

Each package's exports lists only its public entry points, so an import of @rjsf/<package>/lib/<file>.js or @rjsf/<package>/lib throws ERR_PACKAGE_PATH_NOT_EXPORTED in Node, and fails to resolve in every bundler and TypeScript resolution that reads exports (node16, nodenext and bundler). A tool that ignores exports, such as webpack 4 or TypeScript's node10 resolution, still finds the file, but nothing there is part of the public API any more. What still resolves:

  • each package's root, such as @rjsf/core or @rjsf/utils;
  • @rjsf/core/markdown, @rjsf/core/testing, @rjsf/core/rjsf.css and @rjsf/core/rjsf-theme.css;
  • @rjsf/validator-ajv8/compileSchemaValidators and @rjsf/validator-ata/compileSchemaValidators, in place of their lib/compileSchemaValidators paths.

Most deep imports name something the package root exports too, so import it from there:

-import SchemaField from '@rjsf/core/lib/components/fields/SchemaField.js';
-import { getUiOptions } from '@rjsf/utils/lib/getUiOptions.js';
+import { SchemaField } from '@rjsf/core';
+import { getUiOptions } from '@rjsf/utils';

GridType, Operators, LAYOUT_GRID_OPTION, LAYOUT_GRID_UI_OPTION and the GridProps, GridSchemaType, LayoutGridSchemaType and LayoutGridFieldProps types, which only lib/components/fields/LayoutGridField.js exported, are exported from @rjsf/core now, and so is getMatchingPatternProperties(), which only lib/schema/retrieveSchema.js exported, from @rjsf/utils. Label, from lib/components/templates/FieldTemplate/Label.js, is exported from @rjsf/core as FieldLabel. These have no import path any more:

  • A theme's individual components, such as @rjsf/mui/lib/SelectWidget/SelectWidget.js: read them from the theme's Widgets or Templates export, or from registry.widgets and registry.templates in a custom component.
  • @rjsf/core's LayoutGridField and LayoutMultiSchemaField helpers, such as getSchemaDetailsForField() and computeEnumOptions().
  • @rjsf/core's LOOKUP_REGEX, which LayoutGridField.js exported beside LAYOUT_GRID_OPTION.
  • @rjsf/utils' getDefaultFormState() internals, such as computeDefaults(), getObjectDefaults(), getArrayDefaults(), getDefaultBasedOnSchemaType() and AdditionalItemsHandling: call getDefaultFormState() or schemaUtils.getDefaultFormState().
  • @rjsf/utils' calculateIndexScore(): call getClosestMatchingOption().
  • @rjsf/snapshot-tests/package.json, the one package metadata file a package exported: a tool that needs it can read it from node_modules.
  • Every other module that a package's root doesn't export.

@rjsf/shadcn no longer ships prebuilt stylesheets BREAKING CHANGE​

The compiled dist/<theme>.css files (default, amethyst-haze, caffeine, claude, clean-slate, neo-brutalism, pastel-dreams, soft-pop, twitter, vercel) are gone, and so is the src/css directory they were built from. Each was a full Tailwind build — preflight reset included — for one fixed set of design tokens, which is the wrong shape for a library: importing one restyled the whole page and could not follow your own shadcn theme.

@rjsf/shadcn is now styled the way shadcn components are, by the application's Tailwind build. Add the package to your Tailwind sources:

@import 'tailwindcss';
@source "../node_modules/@rjsf/shadcn/lib";

The form then picks up the CSS variables from your shadcn init setup. If you were importing a prebuilt theme for its colors, generate the equivalent variables with tweakcn into your global CSS; the playground's theme sources hold the token sets the old stylesheets were built from.

getDefaultRegistry() removed BREAKING CHANGE​

getDefaultRegistry() bundled the core fields, widgets and templates together with placeholder rootSchema, formContext, translateString and globalFormOptions values that the Form overwrote anyway. Use generateTheme() for all three dictionaries, or the individual generator for the one you need:

-import { getDefaultRegistry } from '@rjsf/core';
+import { generateTheme } from '@rjsf/core';

-const { fields, widgets, templates } = getDefaultRegistry();
+const { fields, widgets, templates } = generateTheme();
-import { getDefaultRegistry } from '@rjsf/core';
+import { generateFields } from '@rjsf/core';

-const { StringField } = getDefaultRegistry().fields;
+const { StringField } = generateFields();

To reuse a single component, import it directly instead — import { StringField } from '@rjsf/core' — so the rest of the core components stay out of your bundle.

Form.getRegistry() is now the buildRegistry() export BREAKING CHANGE​

getRegistry() was a static on the Form class. It is now a standalone buildRegistry() export from @rjsf/core, taking the same arguments:

-import Form from '@rjsf/core';
+import { buildRegistry } from '@rjsf/core';

-const registry = Form.getRegistry(props, schema, schemaUtils);
+const registry = buildRegistry(props, schema, schemaUtils);

Most code never needs it. Inside a field, widget or template, use the registry prop you already receive — it is the registry the Form actually rendered with, including its real schemaUtils. To build one for a test, use getTestRegistry() from @rjsf/core/testing. For the core component dictionaries on their own, use generateTheme().

_internalFormWrapper removed BREAKING CHANGE​

The private _internalFormWrapper prop, and its entry in the ThemeProps a theme passes to withTheme(), have been removed. It existed so @rjsf/semantic-ui could wrap the <form> element in that library's own Form component, and it was the only theme that ever used it. Use the public tagName prop to render a different element in place of <form>.

getTestRegistry() moved to @rjsf/core/testing BREAKING CHANGE​

Update the import and add @rjsf/validator-ajv8 to your dev dependencies:

-import { getTestRegistry } from '@rjsf/core';
+import { getTestRegistry } from '@rjsf/core/testing';
npm install --save-dev @rjsf/validator-ajv8

@rjsf/validator-ajv8 is now an optional peer dependency of @rjsf/core that only this entry point needs, so it no longer ships as a transitive dependency of the main entry. The arguments are unchanged: getTestRegistry(schema, {}, templates) still works. It takes an optional classMap after presentation, layered over @rjsf/core's as a Form's is, so a theme can test a core template under its own class map.

Registry members are read-only BREAKING CHANGE​

Every member of the Registry type in @rjsf/utils is now readonly. The registry is the plumbing Form builds from its props and hands down to fields, widgets and templates; nothing is supposed to write back into it, and now nothing can.

This is only a breaking change if you assigned into a registry you received, e.g. registry.globalUiOptions = {...} or registry.formContext = {...}. Build a modified copy instead and pass that down:

-registry.globalUiOptions = { enableMarkdownInHelp: true };
+const registry = { ...baseRegistry, globalUiOptions: { enableMarkdownInHelp: true } };

Only the registry's own members are read-only. The component maps it holds are not deeply read-only, so registry.widgets.MyWidget = ... still compiles; override widgets, fields and templates through the Form props or withTheme() rather than by writing into the maps.

The registry that getTestRegistry() returns is additionally frozen at runtime, along with its fields, templates and widgets maps and the nested ButtonTemplates, so a test that mutates it throws instead of silently affecting later assertions. The freeze is shallow by design: a deep freeze would reach objects the caller owns, such as formContext, rootSchema and the validator's internal caches.

SchemaExamples is a memo component BREAKING CHANGE​

@rjsf/core exports SchemaExamples wrapped in memo(), so that a keystroke in an input does not render its <datalist> of suggestions again. Render it as an element. A template that calls it as a function still type-checks, and throws SchemaExamples is not a function on its first render:

// Before
{SchemaExamples({ id, schema })}

// After
<SchemaExamples id={id} schema={schema} />

Registry has a required presentation BREAKING CHANGE​

The Registry type in @rjsf/utils has a new required member, presentation: the MergedThemePresentation that declares which parts of a field each registered field and widget draws itself, merged from @rjsf/core's declarations, the theme's and the Form's new presentation prop.

This is only a breaking change if you build a Registry object by hand, e.g. in a test. Build it with buildRegistry() or getTestRegistry() from @rjsf/core/testing, which fill it in, or add it yourself:

+import { mergeThemePresentation } from '@rjsf/utils';

const registry: Registry = {
fields,
templates,
widgets,
+ presentation: mergeThemePresentation(),
// ...
};

getTestRegistry() takes the presentation as a new last argument, and freezes it along with the component maps.

Registry has a required classMap BREAKING CHANGE​

The Registry type in @rjsf/utils has a new required member, classMap: the MergedClassMap of class names the elements @rjsf/core renders carry, one key per element, merged from @rjsf/core's defaults, the theme's classMap and the Form's new classMap prop. @rjsf/core's components read their classes from it, beside the rjsf- markers they always render.

This is only a breaking change if you build a Registry object by hand, e.g. in a test. Build it with buildRegistry() or getTestRegistry() from @rjsf/core/testing, which fill it in, or add it yourself with every key of ClassMap, leaving a key without a class undefined rather than '', which renders an empty class attribute, or an empty object cast to MergedClassMap where nothing reads it:

+import type { MergedClassMap } from '@rjsf/utils';

const registry: Registry = {
fields,
templates,
widgets,
presentation: mergeThemePresentation(),
+ classMap: {} as MergedClassMap,
// ...
};

@rjsf/core's Label, which only lib/components/templates/FieldTemplate/Label.js exported, is exported from @rjsf/core as FieldLabel now that deep imports are removed. It takes a className and no longer renders control-label itself; pass joinClassNames('rjsf-field-label', registry.classMap.fieldLabel), as core's templates do, to give it the label marker and the theme's label class.

@rjsf/core renders unstyled markup; Bootstrap 3 moved to @rjsf/bootstrap3 BREAKING CHANGE​

@rjsf/core's widgets and templates render toolkit-independent HTML5 markup. No Bootstrap 3 class is rendered: its default classMap is empty, the inline pixel offsets tuned to Bootstrap 3 are gone, a hidden field uses the hidden attribute, and the buttons are <button class="rjsf-button rjsf-button-{kind}"> with an inline SVG icon in place of a glyphicon; IconButton draws only the plus, copy, arrow-up, arrow-down and remove icons, and renders no icon for any other name, so pass another icon as an element. The array item, the additional-property row, the add button and the GridTemplate lay themselves out through the optional layout stylesheet, @rjsf/core/rjsf.css, in place of Bootstrap's grid classes, and @rjsf/core/rjsf-theme.css adds an optional default look on top of it; every rule in both is a zero-specificity :where(.rjsf …) in @layer rjsf, with --rjsf-* custom properties, so your own CSS overrides it, and without them a form renders with the browser's own controls. An array item's buttons, and an additional property's key input and remove button, sit level with the field's control: rjsf.css lays the field's label, description and control out on the row's grid rows, which it finds by core's rjsf-field-label class and by the description's rjsf-field-description class or id, so a custom FieldTemplate keeps that layout by keeping those on its parts; see Lining up a custom FieldTemplate. A field's state is an attribute rather than a class: data-invalid on the field's wrapper, label, description and help while it shows its own errors, and data-disabled on a disabled or read-only checkbox or radio option, so CSS reading Bootstrap's has-error selects [data-invalid] instead; the wrapper keeps its rjsf-field-error class too, which every theme's wrapper carries. The GridTemplate takes a column's width as a span prop ('ui:col': { span: 4 }) in place of a col-xs-4 class, and adds rjsf-grid-row and rjsf-grid-column itself. Every marker class core renders carries the rjsf- prefix, so CSS or tests reading the unprefixed ones follow: rjsf-field-description, rjsf-error-detail, rjsf-errors, rjsf-required, rjsf-unsupported-field, rjsf-field-radio-group, rjsf-checkboxes, rjsf-file-info, rjsf-range-view, rjsf-btn-now, rjsf-btn-clear and the like, with new markers such as rjsf-field-label, rjsf-field-help, rjsf-checkbox and rjsf-radio on the elements that carried only a Bootstrap class. The <form> keeps its rjsf class beside the className prop rather than in place of it, so a form given a className to keep .rjsf rules off it now takes them, rjsf.css and rjsf-theme.css included. TitleField renders a <div class="rjsf-title"> rather than a <legend> where it heads no <fieldset>: a layout grid's header, so a legend selector aimed at that title aims at .rjsf-title. DateElement in @rjsf/utils no longer defaults its className to Bootstrap 3's form-control, so a theme whose SelectWidget renders the className it is handed, and which renders DateElement without one, as core's AltDateWidget does, no longer puts form-control on its date selects; @rjsf/shadcn's alt-date selects lose a class the theme doesn't style, while @rjsf/fluentui-rc's SelectWidget adds it itself, so its date selects carry it once rather than twice. A theme that wants it passes className='form-control'.

An app that keeps Bootstrap 3 moves to the deprecated @rjsf/bootstrap3 package, which renders the markup @rjsf/core rendered through v6, with the prefixed markers, and is removed in v8:

-import Form from '@rjsf/core';
+import Form from '@rjsf/bootstrap3';

or withTheme(Theme) with its Theme export. A layout grid written for the Bootstrap 3 grid keeps its className: 'row' and className: 'col-xs-4' properties there; under @rjsf/core it uses span:

'ui:row': {
- className: 'row',
children: [
- { 'ui:col': { className: 'col-xs-4', children: ['first'] } },
+ { 'ui:col': { span: 4, children: ['first'] } },
],
},

The other themes fall back to core for a few components, UnsupportedField in every one, FileWidget and ColorWidget in most, and AltDateWidget in @rjsf/daisyui, @rjsf/fluentui-rc, @rjsf/mui, @rjsf/react-bootstrap and @rjsf/shadcn, which render the prefixed markers too; @rjsf/react-bootstrap gives the AltDateWidget Bootstrap 5 classes through its classMap, @rjsf/daisyui daisyUI ones through its own, and @rjsf/shadcn Tailwind ones through its own. @rjsf/daisyui and @rjsf/shadcn go further and render most of core's templates through a class map of their own; their sections below list which.

@rjsf/daisyui renders core's components through its class map BREAKING CHANGE​

@rjsf/daisyui used to copy most of @rjsf/core's templates and widgets with daisyUI classes on them, and the copies had drifted: its TextareaWidget dropped placeholder and options.emptyValue, its BaseInputTemplate disabled a read-only input rather than marking it read-only. The theme now declares a classMap and renders core's components wherever its own were such copies, keeping only the ones whose markup is its own: FieldTemplate, ObjectFieldTemplate, WrapIfAdditionalTemplate, ArrayFieldTemplate, ArrayFieldItemButtonsTemplate, TitleField, ArrayFieldTitleTemplate and GridTemplate, and the checkbox, toggle, select, date, time and rating widgets.

What that changes in the rendered form:

  • The removed components' form-control wrappers are gone. daisyUI 5 has no such class, so nothing is styled differently, but CSS or a test that reached an input through one has to use core's markers: rjsf-field-description, rjsf-field-help, rjsf-error-detail, rjsf-errors, rjsf-checkbox, rjsf-radio, rjsf-field-range-wrapper, rjsf-alt-date-list.
  • The removed components' own marker classes are gone with them, so CSS or a test selecting one uses core's: rjsf-field-error-template → rjsf-error-detail, rjsf-field-help-template → rjsf-field-help, description-field → rjsf-field-description, error-list → rjsf-errors and btn-add → rjsf-btn-add. A field's errors carry its error id on the <ul> itself: ul#root_x__error.
  • The add, remove, copy, move and clear buttons are core's IconButton, with its inline SVG icons in place of Font Awesome's. A className a custom template passes them lands beside the theme's btn btn-sm. Those classes now come from the class map's iconButton, which core's IconButton reads, rather than from the className the theme hands the button templates, so a custom AddButton, RemoveButton, CopyButton, MoveUpButton or MoveDownButton that renders its own <button> with that className adds btn itself, or reads registry.classMap.iconButton.
  • The error list has an Errors heading, as core's does.
  • A textarea has the browser's default number of rows unless ui:options.rows sets one; it was 5.
  • The alt-date widget renders its selects in one row with the Now and Clear buttons, with no label above each select; each select is still named <field label>, <part> for assistive technology.
  • ui:options.multiple no longer makes a file input accept several files. The file input is core's, which accepts several where the field is an array of files (type: 'array' with items of format: 'data-url'), as in every other theme; a single string field holds one file.
  • ui:enumDisabled dims a whole checkbox or radio option, not only its text, through data-disabled: variants on the data-disabled attribute core sets on it.
  • An array item is core's <div class="rjsf-array-item"> rather than a <fieldset>, rounded at the top of the first item and the bottom of the last by first: and last: variants, so a lone item is rounded at both ends; the items no longer stack by a per-index z-* class.
  • The radio and checkboxes widgets are core's, and CheckboxesWidget names its group from the field's presentation alone. A custom field that renders registry.widgets.CheckboxesWidget without passing the presentation it was handed leaves the group unnamed, and one that renders RadioWidget that way leaves it named by the label it hands it, else its placeholder, else a generic prompt, where the theme's own widgets pointed it at the FieldTemplate's label; pass presentation on.

To restyle one of these elements, pass a classMap to the Form: a class replaces the theme's class for that element, and a function of the theme's class extends it, as the custom themes page describes. The theme's classes carry spacing too, such as checkboxInput's and radioInput's mr-2 before the option's text, so extend them with a function to keep it. A Form put together from the theme's generateTemplates() and generateWidgets() rather than generateTheme() passes classMap={generateClassMap()} too, or core's components render without the theme's classes:

-import { generateTemplates, generateWidgets } from '@rjsf/daisyui';
+import { generateClassMap, generateTemplates, generateWidgets } from '@rjsf/daisyui';

-const Form = withTheme({ templates: generateTemplates(), widgets: generateWidgets() });
+const Form = withTheme({ templates: generateTemplates(), widgets: generateWidgets(), classMap: generateClassMap() });

A custom template or widget that imported one of the removed components from the theme imports it from @rjsf/core instead, or reads it from registry.templates or registry.widgets.

@rjsf/shadcn renders core's plain-HTML templates through its class map BREAKING CHANGE​

@rjsf/shadcn declares a classMap and renders @rjsf/core's FieldTemplate, DescriptionField (core's DescriptionFieldTemplate), FieldErrorTemplate, FieldHelpTemplate, MultiSchemaFieldTemplate, ArrayFieldTemplate, ArrayFieldItemTemplate and ObjectFieldTemplate with Tailwind classes, in place of its own copies, and styles core's AltDateWidget, which it falls back to, through the same class map: its parts in one wrapping row, with Now and Clear as outline buttons. Its widgets and the templates built on its components/ui primitives (BaseInputTemplate, the buttons, ErrorList, CyclicSchemaExpandTemplate, OptionalDataControlsTemplate, TitleField) stay, and so do WrapIfAdditionalTemplate and GridTemplate.

What that changes in the rendered form:

  • An object or an array renders in a <fieldset>, and an array's items in core's rjsf-array-item-list, each item a grid with its buttons in an rjsf-array-item-toolbox beside the field, in place of the nested divs. The buttons are top-aligned and sit level with the field's input, past its label and description however far they wrap or however a classMap restyles fieldLabel or fieldDescription, as core's rjsf.css lays them out; a custom FieldTemplate keeps that by keeping core's classes.
  • TitleField draws an object's or array's title in a <legend>, which names its <fieldset>, followed by the separator, in place of a div holding both.
  • A field's description is drawn above its widget, as core draws it, rather than below. CheckboxWidget rendered without a plan draws no description of its own where ui:widget is the checkbox alias, since core's FieldTemplate draws it there, as @rjsf/core's checkbox does.
  • A field's errors are core's <ul class="rjsf-error-detail"> of <li> items, in place of a div of spans.
  • A field's label, description and help turn destructive through a data-invalid:text-destructive variant beside their class, on the data-invalid attribute core sets while the field shows its own errors.
  • A hidden field uses the hidden attribute rather than a hidden class.
  • AddButton renders its button with no row around it; the arrayFieldset and objectFieldset class-map entries align it to the end of the array or object, so one placed in a custom template carries no margin of its own.
  • WrapIfAdditionalTemplate lays an extra key's label and input, the value and the remove button out on shared grid rows, rather than by pixel offsets, so the key input and the remove button sit level with the value's input however far its label or description wraps, whatever errors or help follow it, as under @rjsf/core (see Lining up a custom FieldTemplate).

CSS or tests that reached these elements through the old div structure use core's markers instead. To restyle one of them, pass a classMap to the Form, as the custom themes page describes. A Form put together from the theme's generateTemplates() and generateWidgets() rather than generateTheme() passes classMap={generateClassMap()} too, or core's templates render without the theme's classes. A custom template that imported one of the removed templates from the theme imports it from @rjsf/core instead, or reads it from registry.templates.

schema, uiSchema and registry props are read-only BREAKING CHANGE​

schema, uiSchema and registry are now readonly on RJSFBaseProps, which every field, widget and template props type extends, and on SubmitButtonProps, which declares its own. These are values the form builds and passes down; writing back into them never reached the form and now fails to compile.

This is only a breaking change if you assigned into the props your custom component received, e.g. props.schema = modifiedSchema. Spread a copy and use that instead, which also drops the modifiers:

-props.schema = { ...props.schema, title: 'Custom' };
+const schema = { ...props.schema, title: 'Custom' };

Note that these props are read-only, not deeply immutable. The objects behind them are shared with the form, so mutating props.schema.title in place is still a bug even though it compiles.

Markdown is an opt-in template​

markdown-to-jsx is no longer part of the @rjsf/core bundle (it was a third of it) and is now an optional peer dependency instead of a dependency, so @rjsf/core no longer installs it. Markdown is rendered through the MarkdownTemplate, whose core default is plain text; @rjsf/core/markdown provides the markdown-to-jsx implementation. Install markdown-to-jsx and register it:

import Form from '@rjsf/core';
import MarkdownTemplate from '@rjsf/core/markdown';

<Form
schema={schema}
validator={validator}
templates={{ MarkdownTemplate }}
uiSchema={{ 'ui:enableMarkdownInDescription': true }}
/>;

The ui:enableMarkdownInDescription and ui:enableMarkdownInHelp options remain independent, default to false, and can still be set through ui:globalOptions. Translatable strings that allow markdown go through RichDescription, so they also need ui:enableMarkdownInDescription. RichDescription.TEST_IDS and RichHelp.TEST_IDS no longer exist, since neither component renders markdown itself any more; the markdown test id lives on the renderer as MarkdownTemplate.TEST_IDS.markdown from @rjsf/core/markdown. Raw HTML is never parsed, including in FileWidget's file information, which used to allow it.

The English InvalidObjectField, UnsupportedField* and FilesInfo translatable strings no longer use markdown syntax. FileWidget's file information used to render through markdown-to-jsx unconditionally, which is what made the file name bold; it now goes through RichDescription like every other translatable string, so it is plain text unless you register a MarkdownTemplate, set ui:enableMarkdownInDescription, and supply a translateString that marks the name up. A custom translateString that returns markdown keeps working when the template is registered and ui:enableMarkdownInDescription is enabled.

Theme removals​

@rjsf/primereact​

This theme has been removed because its latest version (11) now requires a paid subscription and violates our MIT licensing.

If you want to continue using PrimeReact with your v11 paid subscription, you are welcome to migrate the v6 version of @rjsf/primereact yourself.

@rjsf/semantic-ui​

This theme has been removed as it does not support React 19 and has become unmaintained.

Theme version support changes​

@rjsf/antd​

Version 7 is dropping official support for antd version 5. You must upgrade to antd 6.7 or later. Earlier releases won't do: antd 6.0.0-6.3.5 crash @rjsf/antd forms on first render, fixed upstream in ant-design/ant-design#57636, and releases before 6.7 don't understand a labelAlign of 'start' or 'end'

@rjsf/mui​

Version 7 is dropping official support for @mui/material/@mui/icons-material version 7. You must upgrade to version 9.

@rjsf/mantine​

Version 7 is dropping official support for mantine version 8. You must upgrade to version 9.6.3 or later, which requires React 19.2 or later.

Mantine 9 also renamed Grid's gutter prop to gap. GridTemplate maps a gutter value from ui:row/ui:col options to Mantine's gap prop for backward compatibility (an explicit gap wins if both are provided), but existing ui:row/ui:col options that use gutter should be renamed to gap going forward.

@rjsf/chakra-ui no longer requires chakra-react-select BREAKING CHANGE​

chakra-react-select has been removed from @rjsf/chakra-ui's peerDependencies; nothing in the package imports it any more, now that SelectWidget is built on Chakra's own Select.

No code change is required. If you installed chakra-react-select only to satisfy this peer dependency, you can drop it; if your own code uses it, keep it and declare it as a direct dependency of your app.

@rjsf/chakra-ui applies the chakra option, and getChakra() takes the options BREAKING CHANGE​

The chakra option never reached a widget: each one called getChakra({ uiSchema }), and getChakra() read ui:options off that wrapper and found none. The widgets now apply it, so a uiSchema that carries a chakra option changes how its field looks. Remove the option where the styling is not wanted.

getChakra() takes the widget's options, the merged ones getUiOptions() returns, instead of a uiSchema, so a chakra set through ui:globalOptions or the ui:chakra shorthand applies as one in ui:options does. A custom widget or template that calls it passes its options:

// Before
const chakraProps = getChakra(uiSchema);

// After
const chakraProps = getChakra(options);

A template whose props carry no options, such as a FieldTemplate or an ObjectFieldTemplate, merges them from its uiSchema first:

const chakraProps = getChakra(getUiOptions(uiSchema, registry.globalUiOptions));

A call that still passes a uiSchema, or a uiSchema in a { uiSchema } wrapper, no longer compiles.

It returns JsxStyleProps instead of Field.RootProps, and keeps every Chakra style prop, including the ones that share a name with an HTML attribute, such as color and width. The chakra option of the theme's UiSchema type is JsxStyleProps too, so a non-style prop, such as invalid, required or onClick, no longer compiles in the ui:options of the uiSchema's root. In a nested field's ui:options and under ui:chakra the option is not typed, so such a prop still compiles there; under ui:globalOptions it is typed by ChakraGlobalUISchemaOptions. getChakra() always dropped those, wherever they are set; set them on your own widget or template instead.

@emotion/is-prop-valid is no longer a dependency of @rjsf/chakra-ui. If your own code imports it, declare it as a direct dependency of your app.

@rjsf/snapshot-tests no longer requires @testing-library/jest-dom BREAKING CHANGE​

@testing-library/jest-dom has been removed from @rjsf/snapshot-tests's peerDependencies; none of its suites use a jest-dom matcher.

No code change is required. If your own tests use jest-dom's matchers, declare @testing-library/jest-dom as a direct dev dependency, since npm no longer installs it as a peer of @rjsf/snapshot-tests.

Each RJSF warning is logged once BREAKING CHANGE​

The warnings @rjsf/utils, @rjsf/core and the validators raise while rendering now go through logOnce(), which logs each one only the first time it is seen on the page, so a warning is no longer repeated on every re-render (#3051). What has been logged is remembered until the page reloads, so if your tests assert on an RJSF warning, call resetLogOnce() before each test in your Vitest or Jest setup file; otherwise only the first test to trigger a warning sees it logged.

+import { resetLogOnce } from '@rjsf/utils';
+
+beforeEach(resetLogOnce);

Under Vitest's default globals: false, also import beforeEach from vitest.

Node support​

Version 7 supports only the active Node.js LTS lines: ^22.18.0 || ^24.11.0 || >=26.0.0. Node 20, Node 23, Node 25, and Node 24 releases before 24.11.0 are no longer supported, as they are not maintained versions. Node 22.18.0 is the floor because it is the first release to enable native TypeScript type stripping by default.

All PR and branch builds are running against Node 22, 24 and 26

Dropping Node 20 also let us upgrade pnpm past version 10, which had been pinned because pnpm 11+ requires Node >=22.13. We're now on the latest pnpm release (12).

React version​

RJSF is no longer actively supporting React version < 19.2.x. React 19 is officially supported on all the themes. @rjsf/core declares react >=19.2 as its peer dependency, the release that added <Activity>, whose hidden content Form treats as unmounted.

react-dom is a peer dependency of @rjsf/core BREAKING CHANGE​

@rjsf/core declares react-dom >=19.2 as a peer dependency, for the useFormStatus() hook its submit button reads (see Submitting). A project that renders a Form in the browser already has react-dom at the version of its react, so it has nothing to change. A project that installs @rjsf/core without react-dom, such as a test setup on another renderer, gets a missing peer dependency: add react-dom at the version of react.

Removal of defaultProps support on widgets BREAKING CHANGE​

A widget no longer gets its component's (deprecated) defaultProps.options merged into the options it renders with. React 19 removed defaultProps support for function components, so this merging behavior was already broken for function widgets there and only ever worked for forwardRef/memo-wrapped ones.

If you relied on defaultProps to supply default options for a custom widget, apply the defaults inside the widget itself instead, e.g. via destructuring defaults: const { color = 'red' } = options;. See Custom widget options.

Widget type BREAKING CHANGE​

The Widget<T, S, F> type in @rjsf/utils is now SlotComponent<WidgetProps<T, S, F>>, the slot type Field and every TemplatesType entry share: any function or class component taking those props, including one annotated as a React ComponentType or FunctionComponent. It no longer carries the optional MergedWidget property, which getWidget() used to stamp onto a widget component to cache the wrapper it created for merging defaultProps.options (see above). That caching is gone along with the merging behavior it supported.

This breaks code that referenced Widget['MergedWidget'] directly, and code that assigns a Widget to a React ComponentType annotation, which a SlotComponent does not satisfy; annotate such a value as Widget instead. Passing your own ComponentType into widgets is unaffected.

withTheme() BREAKING CHANGE​

withTheme() now returns a plain function component instead of a forwardRef-wrapped one, now that React 19 supports passing ref as a regular prop to function components.

This is a breaking change if you pass the return value of withTheme() directly to useState() or a state setter, e.g. useState(withTheme(theme)) or setFormComponent(withTheme(theme)). React treats a bare function passed to either as a lazy initializer/updater and calls it immediately with no arguments, which throws (a forwardRef object isn't a function, so this pattern happened to work before). Wrap it in a lazy initializer instead: useState(() => withTheme(theme)).

Its return type changed too; see withTheme() returns a ThemedForm.

FormProps['onSubmit'] event type BREAKING CHANGE​

The event parameter passed to onSubmit is now typed as SubmitEvent<HTMLFormElement> instead of the deprecated FormEvent<any> (both from react; FormEvent doesn't correspond to a real event type and is annotated @deprecated in @types/react in favor of SubmitEvent, ChangeEvent, InputEvent, or SyntheticEvent). This is only a breaking change if you referenced FormEvent directly for your onSubmit handler's event parameter type.

SubmitEvent was added to @types/react in version 19.2.18; earlier 19.2.x patches don't have it and will fail to compile against @rjsf/core's published types with TS2305: Module 'react' has no exported member 'SubmitEvent'. Make sure your project's @types/react is at least 19.2.18.

A promise onSubmit returns makes the form pending BREAKING CHANGE​

When onSubmit returns a promise, as every async function does, the form now ignores another submit until that promise settles, from a submit button, from Enter pressed in a field or from FormRef.submit(), and meanwhile the submit button gets pending and aria-disabled (see Submitting). An onSubmit that returns nothing, or a value that isn't a promise, behaves as before, except that a FormRef.submit() it calls before it returns is ignored, since the form can't tell yet whether it will return a promise. The browser already ignored one that reached requestSubmit() then, while the submit event ran, so only a submit of an edit the inputs have yet to show is newly ignored. Submit the next step once onSubmit has returned instead.

  • A form that should take a second submit while the first is saving, such as one that queues saves, has to return before the save settles: start it without returning its promise, onSubmit={(data) => { void save(data); }}, and handle its errors inside save.
  • A rejection is reported as before: an async onSubmit that throws is reported as one unhandled rejection, from the form's copy of the promise rather than from the promise itself. Only a handler that handled the promise it returned, such as const saving = save(data); saving.catch(log); return saving;, now gets an unhandled rejection it didn't before. Catch inside onSubmit and return a promise that resolves instead.
  • A custom SubmitButton that spreads the props of ui:submitButtonOptions gets aria-disabled, and an onClick that cancels the click, while pending. An onClick those props set still runs first, with the click event, and the submit is ignored. Style [aria-disabled='true'], or read the new pending prop, to show it.

FormRef.reset() passed as an event handler seeds the form with the event BREAKING CHANGE​

FormRef.reset(seed?) now reads its argument, as reset(values) does in react-hook-form and TanStack Form: given data, it makes that data the form's seed and baseline (see reset() goes back to the seed). So reset passed straight as an event handler receives the event as its seed, and a click resets the form to the click event:

<button type='button' onClick={formRef.current?.reset}>
Reset
</button>

Wrap it, so it is called with no argument:

<button type='button' onClick={() => formRef.current?.reset()}>
Reset
</button>

ObjectFieldTemplatePropertyType BREAKING CHANGE​

ObjectFieldTemplatePropertyType in @rjsf/utils is now generic (<T, S, F>, defaulted like every other RJSF generic type), and its content field is typed as ReactElement<Pick<FieldProps<T, S, F>, 'schema' | 'uiSchema'>> instead of a bare ReactElement. ObjectFieldTemplateProps['properties'] threads its own <T, S, F> through to it.

This is only a breaking change if you referenced ObjectFieldTemplatePropertyType with explicit (non-default) generic arguments, or relied on content.props being typed as any.

Removal of deprecated APIs BREAKING CHANGE​

The following APIs, deprecated in prior releases, have been removed:

  • UiSchema['ui:rootFieldId']: use the Form.idPrefix prop instead.
  • Form.getUsedFormData() and Form.getFieldNames() instance methods, and the underlying getUsedFormData/getFieldNames exports from @rjsf/utils: there is no direct replacement.
  • Form.omitExtraData() instance method: use SchemaUtils.omitExtraData(schema, formData) instead (build one with createSchemaUtils, or use the registry.schemaUtils your fields, widgets and templates already receive).
  • FormProps.removeEmptyOptionalObjects: this prop had already stopped doing anything; use omitExtraData instead, which now prunes empty optional objects as part of its filtering.
  • removeOptionalEmptyObjects() exported from @rjsf/utils: use omitExtraData instead, which has the equivalent pruning built in.
  • toPathSchema() exported from @rjsf/utils, and SchemaUtils.toPathSchema(): there is no direct replacement.
  • PathSchema type exported from @rjsf/utils: there is no direct replacement.
  • The boolean options for FormProps.liveValidate and FormProps.liveOmit: use 'onChange' in place of true, or omit the prop (or pass undefined) in place of false.

Generic defaults are unknown instead of any BREAKING CHANGE​

The RJSF generics are <T, S, F>: the form data, the schema, and the form context. T and F used to default to any, so anything read out of formData was unchecked and stayed unchecked however far it travelled. They now default to unknown and FormContextType.

FormContextType is object, so reading a key of an untyped formContext is a type error; see any members of the public types are specific types.

Passing your own type is still the way to get typed form data: Form<MyData>, FieldProps<MyData> and IChangeEvent<MyData> behave as before (apart from IChangeEvent.formData, described below), and Form infers T from formData when you pass it, so <Form formData={data} onChange={…} /> types the handler with no type argument at all. The validators and the theme exports were retyped so they keep working with a typed form; see ValidatorType and customizeValidator() take the schema as their first type argument and withTheme() returns a ThemedForm below.

Values annotated without a type argument still fit a typed form. A const uiSchema: UiSchema = …, a CustomValidator or ErrorTransformer, a handler taking a bare IChangeEvent, and a widget, field or template written against the bare props (function MyWidget(props: WidgetProps), class components included) all go into a Form<MyData>, except a bare UiSchema where MyData, or the element of an array MyData, has a string index signature of a type narrower than unknown, such as Record<string, string>; see any members of the public types are specific types. Passed next to formData they do not change what Form infers either: T comes from formData/initialFormData, and from the other props only when no data is passed, so a typed onSubmit handler still types a form that has no formData. Component slots check their props bivariantly, the way React checks event handlers, which is what lets a component written for unknown data fit a slot typed for MyData.

For refs, supply the form-data type explicitly: createRef<FormRef<MyData>>() or useRef<FormRef<MyData>>(null). A bare FormRef defaults to unknown and can make Form infer unknown even when formData is typed. See Reading Form internals through a ref.

The reverse does not hold: a typed handler does not fit a bare FormProps, since FormProps means FormProps<unknown>. A props object or a wrapper component that takes typed handlers (customValidate: (formData: MyData | undefined, …), onSubmit: (e: IChangeEvent<MyData>) => …) fails with TS2322; type it FormProps<MyData>, as in function MyForm(props: FormProps<MyData>) { return <Form {...props} />; }.

IChangeEvent.formData and FormRef.getFormData() are typed EventFormData<T>: T itself when the data is an object or array, since no change below the root replaces such a root with undefined, and T | undefined for a scalar, which is undefined once its field is cleared. A write at the root itself can empty an object root: setFieldValue('', undefined), or a root-level custom field or widget calling onChange(undefined, ROOT_FIELD_PATH). The type does not reflect that, and the event reaches onChange either way, so allow for undefined in the handler's state if your form does either. So const [data, setData] = useState(initial) with onChange={({ formData }) => setData(formData)} needs no annotation for an object form. For a scalar root the state has to allow undefined:

-const [theme, setTheme] = useState('light');
+const [theme, setTheme] = useState<string | undefined>('light');
<Form schema={{ type: 'string' }} formData={theme} onChange={({ formData }) => setTheme(formData)} … />

Beyond those, what changes is code that relied on the any default. The form data now arrives as unknown, so you either name its type or narrow it.

Name the type — preferred, and what the default is there to encourage:

-const onChange = ({ formData }: IChangeEvent) => save(formData.name);
+const onChange = ({ formData }: IChangeEvent<MyData>) => save(formData.name);

Or narrow it, when the data really is unknown at that point:

-function MyField({ formData }: FieldProps) {
- return <input value={formData} />;
+function MyField({ formData }: FieldProps) {
+ return <input value={typeof formData === 'string' ? formData : ''} />;
}

A custom validator receives unknown for the same reason:

-const customValidate = ({ pass1, pass2 }, errors) => {
+const customValidate: CustomValidator<MyData> = (formData, errors) => {
+ const { pass1, pass2 } = formData ?? {};
if (pass1 !== pass2) {
errors.pass2?.addError("Passwords don't match");
}
return errors;
};

One declaration changed along with the defaults: Registry.schemaUtils is SchemaUtilsType<T, S, F> rather than SchemaUtilsType<T, S>. The omitted F was a latent bug that the any default had been hiding; it only matters if you wrote the type out by hand.

The templates prop of Form and withTheme() checks declared templates BREAKING CHANGE​

The templates prop of Form, and with it the templates of the theme passed to withTheme(), now checks a declared template such as FieldTemplate against its own props, where it accepted any component, so a template written for other props no longer compiles. Type its props as the template's, such as FieldTemplateProps. A custom template written inline in the prop needs a type on its props too; see any members of the public types are specific types.

TemplatesType has a string index signature for custom entries, so an Omit of it with literal keys, such as Omit<TemplatesType, 'ButtonTemplates'>, collapses to that signature and checks none of the declared templates. Type a set of templates as the new PartialTemplatesType, the type of the templates prop of Form, which checks each declared template, takes any of the ButtonTemplates on their own and takes custom entries:

-import { TemplatesType } from '@rjsf/utils';
+import { PartialTemplatesType } from '@rjsf/utils';

-const templates: Partial<Omit<TemplatesType, 'ButtonTemplates'>> = {
+const templates: PartialTemplatesType = {
FieldTemplate: MyFieldTemplate,
MyTemplate,
};

A PartialTemplatesType doesn't fit where a Partial<TemplatesType> is expected, such as the return type of a theme's generateTemplates(), because its ButtonTemplates may leave buttons out. Type a value that goes there as Partial<TemplatesType>, which checks the declared templates too but needs every button in a ButtonTemplates it holds.

To leave out other templates, omit them from the new DeclaredTemplatesType, the part of TemplatesType without that signature, and add the new CustomTemplateEntries, the part with it, if the type holds custom entries too. That signature takes a key you omitted unchecked, so declare it again with the type you want. See Typing a set of templates.

A declared template read back out of the templates prop, such as props.templates?.FieldTemplate in a component that wraps Form, was a ComponentType<any> and is now that template's SlotComponent, such as SlotComponent<FieldTemplateProps>. Rendering it with props the template doesn't take, such as <FieldTemplate {...fieldProps} extra='x' />, no longer compiles; pass only the template's own props. A React ComponentType annotation doesn't take a SlotComponent, so a typeof check before passing it on as one no longer compiles either. Name its props with isComponentType():

+import { isComponentType } from '@rjsf/utils';
+
const { FieldTemplate } = props.templates ?? {};
-if (typeof FieldTemplate === 'function') {
+if (isComponentType<FieldTemplateProps>(FieldTemplate)) {
wrap(FieldTemplate); // wrap(component: ComponentType<FieldTemplateProps>)
}

any members of the public types are specific types BREAKING CHANGE​

Members of the @rjsf/utils types that were typed any now have the type their values really have. This affects code that reads these members, and code that builds the props objects that contain them, such as a custom ArrayField or a test that renders a template directly.

MemberWasIs
onAddItem, onCopyItem, onMoveUpItem, onMoveDownItem, onRemoveItem (ArrayFieldItemButtonsTemplateProps, which ArrayFieldItemTemplateProps.buttonsProps holds) and onAddClick (ArrayFieldTemplateProps)(event?: any) => void(event?: SyntheticEvent<HTMLElement>) => void
style (FieldTemplateProps, UIOptionsType, ui:style)StyleHTMLAttributes<any>CSSProperties
RJSFValidationError.paramsanyRecord<string, unknown>
ValidatorType.rawValidation()rawValidation<Result = any>(…): { errors?: Result[] }rawValidation(…): { errors?: unknown[] }; each validator returns its own error type
A custom template's entry in TemplatesType, alone or in a groupComponentType<any>, Record<string, ComponentType<any>>SlotComponent<never>, Record<string, SlotComponent<never> | undefined>
A nested entry of a UiSchema for unknown data, or for data whose index signature holds unknown values, such as Record<string, unknown>anyunknown
additionalProperties in a UiSchema whose T has no index signatureanyUiSchema<unknown>
WidgetProps.value (and BaseInputTemplateProps.value)anyunknown
The value WidgetProps.onChange and FieldProps.onChange take, and the one WidgetProps.onBlur/onFocus and FieldProps.onBlur/onFocus takeany, T | undefinedunknown
useFileWidgetProps()'s value, and the value its onChange takesstring | string[] | undefined | null, string | null | (string | null)[]unknown
DateElementProps.value, and the value select and handleChange take in useAltDateWidgetProps()any, stringnumber | undefined, unknown
Props FieldProps, WidgetProps and BaseInputTemplateProps don't declareanynot accepted
onKeyRename, onKeyRenameBlur, onRemoveProperty in FieldTemplateProps and WrapIfAdditionalTemplatePropsrequiredoptional, undefined for a field ObjectField doesn't render
An option UIOptionsType doesn't declare, read from ui:options, a uiSchema's ui: key or a widget's optionsanyunknown
FormContextType, the constraint and default of every FRecord<string, any>object
RJSFSchemaJSONSchema7 & Record<string, any>JSONSchema7, which declares the later-draft, OpenAPI and Ajv keywords RJSF or its validators read
RJSFMarkedSchemaJSONSchema7 & Record<string, any> & Record<symbol, boolean | string>JSONSchema7 & Record<symbol, unknown>; see RJSFMarkedSchema has no index signature
GenericObjectType, which isObject() narrows to, @rjsf/mui's mui option holds and @rjsf/core's GridProps and LayoutGridSchemaType build onRecord<string, any>Record<string, unknown>
mergeObjects() and mergeSchemas()take and return GenericObjectTypetake any objects and return GenericObjectType
GenericSymbolObjectTypeGenericObjectType & Record<symbol, boolean | string>removed, so declare it yourself if you use it
GridTemplateProps' other props, and the undeclared keys of UISchemaSubmitButtonOptions.props (ui:submitButtonOptions.props)anyAny object, of which only the declared props read back: GridTemplateProps now also declares className and style, and props declares disabled, className, onClick and onClickCapture
The entry of a field typed any in a typed UiSchema, such as UiSchema<{ meta: any }>['meta'] or a UiSchema<GenericObjectType> fieldanyunknown

Pass these handlers straight to a button's onClick, or call them with no argument. A handler you write for one of these props has to accept any React event on an HTML element, and undefined. An SVG element's event, such as the MouseEvent<SVGSVGElement> of an icon's own onClick, doesn't fit: handle the click on the button around the icon instead.

The currentTarget type says it is never null, but React clears it once dispatch ends. A handler that reads it after an await gets null, so read it first: const target = event?.currentTarget; await confirm(); target?.blur();.

onAddItem, onCopyItem, onMoveUpItem, onMoveDownItem and onRemoveItem act on the index the item rendered with, so a template that calls one after an await has to know the item still sits there: if another item was added, moved or removed meanwhile, the call acts on the row now at that index.

The handlers also read whether the button can act, such as the form's disabled, from the render that handed them out. A call after an await still acts if the form was disabled or made read-only meanwhile, so call the handler before awaiting, or check the lock again before calling it.

-const onAddClick = (event: MouseEvent<HTMLButtonElement>) => { … };
+const onAddClick = (event?: SyntheticEvent<HTMLElement>) => { … };

style was typed as the props of a <style> element, so it accepted keys such as media and nonce that aren't CSS. It now takes the same CSSProperties a style prop does; move anything that isn't a CSS property elsewhere.

RJSFValidationError.params can be missing and its values are unknown, so narrow a value before using it:

-const missing: string = error.params.missingProperty;
+const missing = error.params?.missingProperty;
+if (typeof missing === 'string') { … }

A custom validator that sets params to a value typed with an interface no longer type-checks, since an interface has no index signature. Spread it, as params: { ...params }, or declare it with type.

rawValidation() no longer takes a type argument. Each validator returns the errors of its engine, from customizeValidator(), the default export and createPrecompiledValidator() alike: ErrorObject from Ajv for @rjsf/validator-ajv8, ValidationError from ata-validator for @rjsf/validator-ata, and CFWorkerValidationError for @rjsf/validator-cfworker. Each validator package exports its error type, so an app that doesn't depend on the engine itself can import it from there. Through a bare ValidatorType the errors are unknown[]. To type a stored result, use RawValidationResult from @rjsf/utils, such as RawValidationResult<ErrorObject>:

-import type { ErrorObject } from 'ajv';
+import type { RawValidationResult } from '@rjsf/utils';
+import type { ErrorObject } from '@rjsf/validator-ajv8';

-const [result, setResult] = useState<{ errors?: ErrorObject[] }>();
-setResult(validator.rawValidation<ErrorObject>(schema, formData));
+const [result, setResult] = useState<RawValidationResult<ErrorObject>>();
+setResult(validator.rawValidation(schema, formData));

That diff needs validator to have a concrete type, as customizeValidator(), the default export and createPrecompiledValidator() give it. Code that holds it as ValidatorType, as Form and Registry do, has only unknown[] errors, so it has to assert the engine it knows it has: setResult(validator.rawValidation(schema, formData) as RawValidationResult<ErrorObject>).

A custom validator that hands back another validator's rawValidation() drops its own Result type parameter too, since that call's errors are no longer Result[].

Any component still fits a custom entry in registry.templates, alone or in a group, except a class whose constructor requires a second argument, and naming it in the uiSchema works as before. Reading one back out of registry.templates, or from getTemplates() under a custom name, gives a component whose props type is never, so code that narrowed the lookup and rendered it, or passed it on as a ComponentType, no longer compiles. Name the template's props in the lookup:

+import { isComponentType } from '@rjsf/utils';
+
const { MyTemplate } = registry.templates;
-return typeof MyTemplate === 'function' ? <MyTemplate {...props} /> : null;
+if (!isComponentType<MyTemplateProps>(MyTemplate)) {
+ return null;
+}
+return <MyTemplate {...props} />;

For an entry in a group, narrow the group first, since isComponentType() is false for it, then name the props on the member. A typeof check alone isn't enough to find the group, because what memo(), forwardRef() and lazy() return is an object too:

const { MyGroup } = registry.templates;
if (typeof MyGroup !== 'object' || isComponentType(MyGroup)) {
return null;
}
const { MyTemplate } = MyGroup;
if (!isComponentType<MyTemplateProps>(MyTemplate)) {
return null;
}
return <MyTemplate {...props} />;

Keep reading it from the registry: importing the component directly ignores a theme or templates prop that registers another one under its name.

A template written inline with no type on its props parameter takes never from the same entry, so reading a prop from it no longer compiles. That goes for any custom entry, including one in the templates prop of Form. Annotate the parameter:

-<Form schema={schema} validator={validator} templates={{ MyTemplate: (props) => <div>{props.children}</div> }} />
+<Form schema={schema} validator={validator} templates={{ MyTemplate: (props: MyTemplateProps) => <div>{props.children}</div> }} />

An untyped UiSchema still accepts any nesting, but an entry read back from it is unknown, since nothing says what it holds. So is the entry of a field typed any in a typed one, such as meta in UiSchema<{ meta: any }> or any field of UiSchema<GenericObjectType>. Cast the uiSchema you read to one typed by the data it describes, and narrow the value. Keep WidgetProps' own type argument, which is the widget's value type: the widget's uiSchema is typed by that value, so it needs a cast rather than an annotation to read it as another type. The cast checks nothing, so a wrong one compiles:

function MyWidget({ uiSchema }: WidgetProps<string>) {
- return <span>{uiSchema?.custom['ui:options'].hint}</span>;
+ // the uiSchema is typed by the widget's value, which says nothing about the `custom` entry read from it
+ const ui = uiSchema as UiSchema<{ custom?: string }> | undefined;
+ const hint = ui?.custom?.['ui:options']?.hint;
+ return <span>{typeof hint === 'string' ? hint : null}</span>;
}

A child read by a key known only at runtime, as a custom object field reads one by its property name, has no data type to annotate with. Cast the entry, or read it with getByPath<UiSchema | undefined>(), as ObjectField does:

function MyObjectField({ uiSchema, name }: FieldProps) {
- const options = getUiOptions(uiSchema?.[name]);
+ // `name` is known only at runtime, so nothing types the entry under it
+ const options = getUiOptions(uiSchema?.[name] as UiSchema | undefined);
return <span>{options.title}</span>;
}

Spreading an entry of an untyped UiSchema fails with TS2698 (Spread types may only be created from object types), since the entry is unknown. Annotate the uiSchema it comes from with the data it describes:

-const base: UiSchema = { field: { 'ui:widget': 'textarea' } };
+const base: UiSchema<{ field?: string }> = { field: { 'ui:widget': 'textarea' } };
const uiSchema = { field: { ...base.field, 'ui:help': 'Shown below' } };

An entry read from a UiSchema<Record<string, unknown>> is unknown, so check it with isObject() before reading its keys.

An untyped UiSchema, or a UiSchema<GenericObjectType>, no longer fits a UiSchema<X> where X, or the element of an array X, has a string index signature of a type narrower than unknown, such as the uiSchema of a Form whose formData is a Record<string, string> or a Record<string, string>[]: each key of that signature holds a UiSchema for its value, which the unknown entries don't fit. A Record<string, unknown> target is fine, since its entries are as open as the untyped one's. Likewise a UiSchema<{ meta: any }> no longer fits a UiSchema<{ meta: string }>, or one with any other type for meta. Annotate the uiSchema with the data it describes:

-const uiSchema: UiSchema = { notes: { 'ui:widget': 'textarea' } };
+const uiSchema: UiSchema<Record<string, string>> = { notes: { 'ui:widget': 'textarea' } };

additionalProperties describes data of an unknown type in a UiSchema with no type argument, or whose T has no index signature, so a function written inside it, such as an items function or an inline ui:field, gets unknown for that data rather than any. Annotate its parameter:

const uiSchema: UiSchema = {
additionalProperties: {
- items: (item) => ({ 'ui:title': item.label }),
+ items: (item: { label: string }) => ({ 'ui:title': item.label }),
},
};

Spreading the rest of a generic UiSchema<T, S, F> into a new object no longer gives back a UiSchema<T, S, F>, since the checker can't relate that rest to the type it came from. A generic custom field that strips keys this way needs a cast on the rest, which leaves the keys beside it checked:

const { 'ui:title': _title, ...rest } = uiSchema;
-return { ...rest, 'ui:options': {} };
+return { ...(rest as UiSchema<T, S, F>), 'ui:options': {} };

A widget's value is unknown: a ui:widget can point any widget at data of any type, and some fields hand their widgets a value that isn't the field's data, such as the option index MultiSchemaField gives its selector. Narrow it before use. toInputValue() turns it into what an input's value prop takes:

-const MyWidget = ({ value, onChange }: WidgetProps) => <input value={value ?? ''} onChange={(e) => onChange(e.target.value)} />;
+const MyWidget = ({ value, onChange }: WidgetProps) => (
+ <input value={toInputValue(value)} onChange={(e) => onChange(e.target.value)} />
+);

Whatever a widget passes to onChange is unknown to it, since the field it reports to decides what the value means. A field's onChange takes an unknown value too, since a change reported from inside the field carries the value at the deeper FieldPath it names. A handler you pass as the onChange of a widget or a field can still declare the type it expects, while one you pass as onBlur or onFocus has to take an unknown value.

useAltDateWidgetProps() reads only a string, so the alt-date and alt-datetime widgets of every theme show a number or Date as empty: they write strings, so a field holding either couldn't keep it. Pass the date as the string the widget writes: a date-time such as date.toISOString() for alt-datetime, or a YYYY-MM-DD date for alt-date.

@rjsf/mantine's FileWidget hands FileInput an empty File for each stored file, where it handed the stored data URLs, which aren't Files. A ui:options.valueComponent that read the files' names or contents from its value gets neither: write your own FileWidget instead, whose useFileWidgetProps() gives each file's name, size, type and data URL in filesInfo.

A custom GridTemplate that reads a prop of its own from ui:layoutGrid declares it, as the themes now do:

-function MyGridTemplate({ gap, ...rest }: GridTemplateProps) {
+function MyGridTemplate({ gap, ...rest }: GridTemplateProps & { gap?: number }) {

GridTemplateProps is a type alias, not an interface, so neither an interface that extends it nor a declare module '@rjsf/utils' augmentation of it compiles. Build a props type of your own by intersection instead, and declare a prop an augmentation added on the template that reads it, as above:

-interface MyGridProps extends GridTemplateProps {
- gap?: number;
-}
+type MyGridProps = GridTemplateProps & { gap?: number };

An Omit of GridTemplateProps keeps only its declared props, so it no longer takes the theme's other props. Intersect it with OpenProps, the open part of GridTemplateProps, to keep them:

-type MyRowProps = Omit<GridTemplateProps, 'style'> & { style?: MyStyle };
+type MyRowProps = OpenProps & Omit<GridTemplateProps, 'style'> & { style?: MyStyle };

GridTemplateProps.style is a CSSProperties, which has no key for a CSS custom property, so a style that sets one casts the object:

-const props: GridTemplateProps = { style: { '--my-gap': '8px' } };
+const props: GridTemplateProps = { style: { '--my-gap': '8px' } as CSSProperties };

A custom SubmitButton that reads a prop of its own from ui:submitButtonOptions.props checks for it, then narrows its unknown value. getSubmitButtonOptions() always returns props, the default ones when the uiSchema's are missing or aren't an object. Its return type is SubmitButtonOptions; annotated as UISchemaSubmitButtonOptions, its props are optional again:

-const variant = getSubmitButtonOptions(uiSchema).props?.variant;
+const { props } = getSubmitButtonOptions(uiSchema);
+const variant = 'variant' in props && typeof props.variant === 'string' ? props.variant : undefined;

A handler written inline for an undeclared key of ui:submitButtonOptions.props gets no type for its parameter from UiSchema, so annotate the parameter. onClick and onClickCapture are declared, so theirs still comes from the type:

-const uiSchema: UiSchema = { 'ui:submitButtonOptions': { props: { onMouseEnter: (event) => track(event) } } };
+const uiSchema: UiSchema = {
+ 'ui:submitButtonOptions': { props: { onMouseEnter: (event: MouseEvent<HTMLElement>) => track(event) } },
+};

FieldProps and WidgetProps no longer accept props they don't declare. They declare the ones RJSF passes: on FieldProps the additional-property callbacks onKeyRename, onKeyRenameBlur and onRemoveProperty, propertyNamesEnum, an array item's index and a oneOf/anyOf field's options; on WidgetProps errorSchema, time and autocomplete. A field or widget that takes props of its own declares them, and passing one an undeclared prop is a type error:

-function MyField({ myProp }: FieldProps) {
+function MyField({ myProp }: FieldProps & { myProp?: string }) {

Give a prop of your own a name these props don't declare. The & intersects with a declared prop rather than replacing it, so FieldProps & { options: MyConfig } gives options: S[] & MyConfig, which can't be passed without a cast.

BaseInputTemplateProps declares the type, pattern and accept the widgets set on the input, so passing it another input attribute, such as maxLength, min or autoComplete, is a type error. A widget that passes a theme's own props to the registry's BaseInputTemplate reads it typed with them: @rjsf/core's CoreBaseInputTemplateProps (also for @rjsf/daisyui, which renders core's template) takes the <input> attributes, @rjsf/mui's MuiBaseInputTemplateProps the TextField props, and @rjsf/react-bootstrap's BootstrapBaseInputTemplateProps and @rjsf/shadcn's ShadcnBaseInputTemplateProps an extraProps, which also takes data-* attributes:

-const { BaseInputTemplate } = registry.templates;
+const BaseInputTemplate: SlotComponent<MuiBaseInputTemplateProps> = registry.templates.BaseInputTemplate;
return <BaseInputTemplate {...props} multiline rows={3} />;

That annotation trusts the registered template rather than checking it: a BaseInputTemplate an app registers that ignores multiline still compiles, and renders a single-line input.

The onKeyRename, onKeyRenameBlur and onRemoveProperty a FieldTemplate or WrapIfAdditionalTemplate receives are undefined for a field ObjectField doesn't render, such as the root or an array item, which they always were at runtime. Every property ObjectField renders gets them, so they don't tell an additional property from a declared one:

-onKeyRename(newKey);
+onKeyRename?.(newKey);

An option a theme or custom component reads from ui:options that RJSF doesn't declare is unknown, so check its type before use:

-const { size = 'md' } = props.options;
+const size = typeof props.options.size === 'string' ? props.options.size : 'md';

FormContextType is object, so any object, an interface included, can be a form context. A component generic over the context, or left on the default object, names the keys it reads, each optional, since object promises none:

-const { locale } = registry.formContext;
+const { locale }: { locale?: string } = registry.formContext;

A dynamic uiSchema.items callback gets the context as F too, so one that reads a key passes its context type to UiSchema:

-const uiSchema: UiSchema = {
+const uiSchema: UiSchema<unknown, RJSFSchema, { isAdmin?: boolean }> = {
items: (_itemData, _index, formContext) => (formContext?.isAdmin ? adminItem : userItem),
};

A key computed at runtime, such as a field's id, is read from a member typed as a record:

-const label = registry.formContext.labels[id];
+const { labels = {} }: { labels?: Record<string, string> } = registry.formContext;
+const label = labels[id];

A context that is itself keyed that way can't be annotated as a record, since object isn't assignable to one; the component takes it as F:

-function IdField({ id, registry }: FieldProps) {
+function IdField({ id, registry }: FieldProps<unknown, RJSFSchema, Record<string, string>>) {
const label = registry.formContext[id];

A component that knows the context type takes it as F, as in FieldProps<MyData, RJSFSchema, MyContext>, and reads its keys directly. The annotations above compile only for a context that is generic or the default object; on a known context they fail unless the two types share a key. Under @rjsf/antd, a component generic over the context, or on the default, can annotate with the exported AntdFormContext to read the theme's options, whose rowGutter is what antd's Row takes; a known context extends it, as in interface MyContext extends AntdFormContext, and its keys are read directly.

RJSFSchema is JSONSchema7, which @rjsf/utils augments with the keywords RJSF or its validators read beyond draft 7: $anchor, $dynamicAnchor, $dynamicRef, $recursiveAnchor, $recursiveRef, $vocabulary, contentSchema, dependentRequired, dependentSchemas, minContains, maxContains, prefixItems, unevaluatedItems and unevaluatedProperties from later drafts, OpenAPI's discriminator and components, Ajv's nullable, formatMinimum, formatMaximum, formatExclusiveMinimum and formatExclusiveMaximum, and ajv-errors' errorMessage. A schema with keywords of its own declares them the same way, so they type-check at every depth, a property's subschema included:

-const schema: RJSFSchema = { type: 'object', 'x-group': 'contact' };
+declare module 'json-schema' {
+ interface JSONSchema7 {
+ 'x-group'?: string;
+ }
+}
+const schema: RJSFSchema = {
+ type: 'object',
+ 'x-group': 'contact',
+ properties: { phone: { type: 'string', 'x-group': 'contact' } },
+};

If the declare module fails with TS2664, your project needs @types/json-schema of its own. If type and every other keyword then fail with TS2353, the file holding it needs an import or export. See Keywords of your own.

A type of your own, such as RJSFSchema & { 'x-group'?: string }, which Form infers as S or takes as its second type argument, declares a keyword on the root schema alone, since a subschema is a JSONSchema7Definition.

Since the augmentation is global, code that already augments JSONSchema7 with one of the keywords above under another type no longer compiles (TS2717: "Subsequent property declarations must have the same type"). Declare it with the type @rjsf/utils gives it.

OpenAPI's discriminator is the interface JSONSchema7Discriminator, which declares propertyName and mapping. A field of its own, such as defaultMapping, now fails with TS2353, so declare it by augmenting that interface:

declare module 'json-schema' {
interface JSONSchema7Discriminator {
defaultMapping?: string;
}
}

RJSFSchema has no index signature any more, so schema[key] with a string key and passing a schema as a Record<string, unknown> are type errors. Type the key as keyof RJSFSchema, and the parameter as RJSFSchema.

An object that was only accepted through that index signature now fails with TS2353. A properties or $defs map isn't a schema itself, so type it as Record<string, RJSFSchema>. A schema that carries a marker such as ADDITIONAL_PROPERTY_FLAG is an RJSFMarkedSchema:

-const properties: RJSFSchema = { name: { type: 'string' } };
+const properties: Record<string, RJSFSchema> = { name: { type: 'string' } };

-const schema: RJSFSchema = { type: 'string', [ADDITIONAL_PROPERTY_FLAG]: true };
+const schema: RJSFMarkedSchema = { type: 'string', [ADDITIONAL_PROPERTY_FLAG]: true };

A GenericObjectType, such as a value isObject() narrows, has unknown values, so read a schema through isSchemaObject() instead, and in a function generic over S, cast what mergeObjects() or mergeSchemas() return to S:

-if (isObject(schema)) { const { properties } = schema; … }
+if (isSchemaObject(schema)) { const { properties } = schema; … }
-const merged: S = mergeSchemas(a, b);
+const merged = mergeSchemas(a, b) as S;

A value of an interface type no longer fits a GenericObjectType, such as the mui option, since an interface has no implicit index signature. Spread it, or declare its type with type:

-const uiSchema: UiSchema = { 'ui:options': { mui: slotProps } };
+const uiSchema: UiSchema = { 'ui:options': { mui: { ...slotProps } } };

RJSFMarkedSchema has no index signature and its markers are unknown BREAKING CHANGE​

RJSFMarkedSchema no longer has an index signature for other keys, so marked['foo'] is a TS7053 error ("Element implicitly has an 'any' type"). A keyword of JSONSchema7 or one of the later-draft, OpenAPI and Ajv keywords @rjsf/utils declares, such as marked.$defs, still reads with its type; for a keyword of your own, such as marked['x-custom'], declare it on JSONSchema7 as any members of the public types are specific types shows for RJSFSchema. A marker's value is unknown, so const added: boolean | string = marked[ADDITIONAL_PROPERTY_FLAG] is a TS2322 error. Read a marker with hasSchemaMarker(), which also returns false for a boolean subschema:

-const added: boolean | string = marked[ADDITIONAL_PROPERTY_FLAG];
+const added = hasSchemaMarker(marked, ADDITIONAL_PROPERTY_FLAG);

hasSchemaMarker() takes only the markers that hold a flag, and says whether one is set. Read the value of any other marker, such as the $ref RJSF_REF_KEY holds, by indexing an RJSFMarkedSchema: marked[RJSF_REF_KEY] is unknown, so it has to be narrowed before use. On a schema of another type, Reflect.get(schema, RJSF_REF_KEY), behind an object check, returns any, since the type doesn't declare the key, so give the result the type unknown. Write a marker with Reflect.set().

ADDITIONAL_PROPERTY_FLAG counts only when set to a truthy value BREAKING CHANGE​

In 6.x, WrapIfAdditionalTemplate, in @rjsf/core and every theme, and the label SchemaField picked read ADDITIONAL_PROPERTY_FLAG by its presence, while the rest of RJSF read it by its value. So a schema with the flag set to undefined or false, such as { ...schema, [ADDITIONAL_PROPERTY_FLAG]: false }, drew a key input and remove button and was labelled by its key, but was otherwise a declared property. Those reads now take the flag's value too, so such a field renders as a plain declared field, with no key input or remove button, labelled by its title where it has one.

Setting the flag to true brings the key input back, but makes the field an additional property everywhere else as well: its uiSchema is read from uiSchema.additionalProperties rather than uiSchema[key], under constAsDefaults: 'always' the computed defaults drop it where the object's const doesn't name its key, omitExtraData drops it, and getDisplayLabel() gives an object or array field a label. Set it to true only for a field that is an additional property; otherwise leave it out:

-const extra = { ...schema, [ADDITIONAL_PROPERTY_FLAG]: false };
+const extra = schema;

A custom template that tests ADDITIONAL_PROPERTY_FLAG in schema still draws a key input and remove button for a field whose flag is falsy, unlike the templates RJSF ships. Read the flag with hasSchemaMarker() to match them:

-const additional = ADDITIONAL_PROPERTY_FLAG in schema;
+const additional = hasSchemaMarker(schema, ADDITIONAL_PROPERTY_FLAG);

createPrecompiledValidator() returns its validator class BREAKING CHANGE​

createPrecompiledValidator() returns its class, AJV8PrecompiledValidator from @rjsf/validator-ajv8 or ATAPrecompiledValidator from @rjsf/validator-ata, where it returned ValidatorType. A variable or state it initializes takes that class as its type, so assigning it another validator, such as one from customizeValidator(), no longer type-checks: the other validator lacks the precompiled one's rootSchema, validateFns and other members. Annotate the variable as ValidatorType<S, F>, or pass it as useState<ValidatorType>()'s type argument. That types its rawValidation() errors as unknown[] again, rather than ErrorObject[]:

-let validator = createPrecompiledValidator(validateFns, schema);
+let validator: ValidatorType = createPrecompiledValidator(validateFns, schema);
validator = customizeValidator();

A localizer gets quoted property names from a precompiled or ata validator BREAKING CHANGE​

A localizer handed to createPrecompiledValidator() from @rjsf/validator-ajv8, or to customizeValidator() or createPrecompiledValidator() from @rjsf/validator-ata, now gets each property name in the missingProperty and property params of an error quoted, such as 'foo' where it got foo, as customizeValidator() from @rjsf/validator-ajv8 already did; for @rjsf/validator-ajv8, so is each name in deps. The quotes let these validators replace the name in a localized message with the property's title or ui:title. A property with no title keeps its key, now quoted, so with ajv-i18n's Spanish messages a missing untitled foo reads debe tener la propiedad requerida 'foo' where it read debe tener la propiedad requerida foo; update the snapshots that hold such messages. A localizer that looks a property up by its param, such as in a map of labels, strips the quotes first:

-error.message = labels[error.params.missingProperty];
+error.message = labels[error.params.missingProperty.slice(1, -1)];

The localizer gets copies of the errors, and only the message it sets on each is kept. The errors that rawValidation() returns keep their params unquoted.

ValidatorType and customizeValidator() take the schema as their first type argument BREAKING CHANGE​

ValidatorType<S, F> puts the form data type on validateFormData<T>(), since a validator checks data of any shape against a schema. import validator from '@rjsf/validator-ajv8' (and the same export from @rjsf/validator-ata and @rjsf/validator-cfworker) fits every Form, and customizeValidator() takes only <S, F>:

-const validator = customizeValidator<MyData>();
+const validator = customizeValidator();

<Form<MyData> schema={schema} validator={validator} />

The first type argument is now the schema type, so a leftover customizeValidator<MyData>() or ValidatorType<MyData> does not always fail where it is declared. If MyData shares no keys with JSONSchema7, the declaration is the error (TS2559: Type 'MyData' has no properties in common with type 'JSONSchema7'). A shared key of a conflicting type fails at the declaration too (TS2344: Type 'MyData' does not satisfy the constraint 'JSONSchema7'). Otherwise (a title: string, say) the declaration compiles with the schema typed as MyData: a Form that infers its types from it reports the mismatch on its schema prop (TS2322: Type 'RJSFSchema' is not assignable to type …, or TS2741: Property '…' is missing in type 'RJSFSchema' when MyData also has a required key that JSONSchema7 lacks), and a Form<MyData> may not report it at all, since the validator's methods are checked bivariantly. In every case, drop the type argument.

BaseInputTemplate puts multiple only on a file input BREAKING CHANGE​

ArrayField passes multiple to the widget of every multi-select, whatever widget ui:widget names, and a BaseInputTemplate can't tell that multiple from one a widget passes on purpose. Every theme's BaseInputTemplate now puts multiple on a file input only. Before, core and daisyui put it on any input, and react-bootstrap and shadcn put a multiple in extraProps on any input, so an email input took a comma-separated list of addresses.

A widget that wants an email input to take several addresses renders the <input> itself rather than the registry's BaseInputTemplate, and so carries what the template did for it: the name, the disabled, readonly and required state, ui:emptyValue for a cleared input, the onBlur and onFocus that drive blur validation, and the ARIA attributes of getControlAttributes():

-return <BaseInputTemplate {...props} type='email' multiple />;
+const { id, htmlName, value, disabled, readonly, required, autofocus, options, onChange, onBlur, onFocus } = props;
+return (
+ <input
+ id={id}
+ name={htmlName || id}
+ type='email'
+ multiple
+ value={toInputValue(value)}
+ disabled={disabled}
+ readOnly={readonly}
+ required={required}
+ autoFocus={autofocus}
+ onChange={(e) => onChange(e.target.value === '' ? options.emptyValue : e.target.value)}
+ onBlur={(e) => onBlur(id, e.target.value)}
+ onFocus={(e) => onFocus(id, e.target.value)}
+ {...getControlAttributes(props)}
+ />
+);

It leaves out the theme's styling, the examples list and the clear button, which a widget that needs them renders too.

@rjsf/daisyui ignores a size, color or colorGradient of another type BREAKING CHANGE​

@rjsf/daisyui's RatingWidget and ToggleWidget read a size or color option only as a string, trimmed, and fall back to the default for any other or a blank one, so size: 5 no longer gives a toggle-5 or rating-5 class. RatingWidget draws its color gradient only for a colorGradient of true, where any truthy value, such as 1, 'true' or even 'false', drew it. Write the options with those types:

- 'ui:options': { size: 5, colorGradient: 'true' },
+ 'ui:options': { size: '5', colorGradient: true },

AltDateWidget uses the default years for a yearsRange reaching before 100 BREAKING CHANGE​

useAltDateWidgetProps(), and so the AltDateWidget of @rjsf/core (which most themes fall back to), @rjsf/antd, @rjsf/chakra-ui and @rjsf/mantine, show the default years, 1900 to two years from now, for a yearsRange whose years reach before 100 or after 9999, once a relative range is counted back from the current year. They offered such a range as given, but wrote a year before 100 as 19xx, since Date.UTC() reads 0-99 that way. A range such as [50, 2030] now loses its own years as a whole, so a stored year outside the default ones, such as 1500, shows as blank. Start the range at 100 or later to keep its years:

- 'ui:options': { yearsRange: [50, 2030] },
+ 'ui:options': { yearsRange: [100, 2030] },

withTheme() returns a ThemedForm BREAKING CHANGE​

withTheme() returns the new ThemedForm<T, S, F> rather than ComponentType<FormProps<T, S, F>>: a function component that is generic over the form data like Form, so a theme's default Form export infers T from formData or takes it as <Form<MyData> />. The Theme, Templates and Widgets exports keep each component's own generic type, so withTheme<MyData>(Theme) and { ...Templates, FieldTemplate: MyFieldTemplate } typecheck for any T. generateForm<T, S, F>() and generateTheme<T, S, F>() still exist for pinning the types up front. An existing ComponentType<FormProps<…>> annotation of the return value still compiles, but it fixes T again; drop it to keep the per-use inference. Since ThemedForm is a plain generic function type, setting or reading component statics such as displayName on the return value no longer type-checks.

ErrorSchema<T> and FormValidation<T> node shapes BREAKING CHANGE​

ErrorSchema<T> and FormValidation<T> now describe the tree that toErrorSchema() and createErrorHandler() actually build. Previously they resolved to the wrong shape for anything but a plain object: a leaf field's node resolved to that field's own type, an array's node to an array rather than a node keyed by index, and ErrorSchema<any> to an index signature that no concrete error schema was assignable to. In practice a typed error schema never type-checked.

There are two things to do.

Drop the casts you used to work around the old shapes. The value type-checks on its own now:

-const extraErrors = { name: { __errors: ['too short'] } } as unknown as ErrorSchema;
+const extraErrors: ErrorSchema<FormData> = { name: { __errors: ['too short'] } };

Reach into nodes with ?.. Every node is optional, because the tree only contains the fields that actually have errors:

-errors.address.city.addError('unknown city');
+errors.address?.city?.addError('unknown city');

For reference, this is the shape a typed error schema now accepts — a node per field, keyed by index for arrays, with no children below a leaf:

type FormData = { name: string; address?: { city: string }; tags: string[] };

const errors: ErrorSchema<FormData> = {
__errors: ['form-level problem'],
name: { __errors: ['too short'] },
address: { city: { __errors: ['unknown city'] } },
tags: { 0: { __errors: ['not allowed'] } },
};

A field whose type is a union — what a oneOf/anyOf property gives you — accepts the children of every member it can hold, since which one is present depends on the current value:

type FormData = { thing: string | { city: string } };

const errors: ErrorSchema<FormData> = { thing: { city: { __errors: ['unknown city'] } } };

FormValidation<T>, the tree a customValidate function receives, is the same tree with addError() on every node.

In full, the children a node accepts are decided by the type of the data it describes:

Field's typeChildren of its error node
An objectA node per property
An arrayA node per index, keyed as 0, 1, …
A tupleA node per position, each of that position's own type
A tuple with a rest elementIts declared positions as above, the rest as an array's are
A primitive (string, number, boolean)None — a leaf holds only __errors
Date or FileNone — an object holding no form fields
A unionThe children of every member
any or unknownUnconstrained

An optional property is described by the type it holds, so address?: { city: string } gets the same node as address: { city: string }. Any other class instance used as a field value (a Money or LatLng value object, say) is treated as an object, so its properties are accepted as children even though nothing produces errors for them.

If you leave T at its default (unknown) and never annotate an error schema, nothing changes for you.

An array field's node is never an array, which the numeric index signature used to let through, so a typed error schema keys the items by index:

-const extraErrors: ErrorSchema<FormData> = { tags: [{ __errors: ['not allowed'] }] };
+const extraErrors: ErrorSchema<FormData> = { tags: { 0: { __errors: ['not allowed'] } } };

A field typed as a union of an array and an object with its own length field, such as { length: number } | string[], still accepts an array as its node, since that length field can hold errors.

The array is rejected through a length key on an array field's node that can hold nothing, so that key shows through: keyof of such a node includes length, editors offer it in autocompletion, and Required<> of the node asks for a length no value can supply.

An array of item errors is keyed by index BREAKING CHANGE​

An extraErrors, a field's raise or a customValidate result that holds an array for the items of an array field, which an untyped error schema still accepts, is handled as the object keyed by index, so its errors are listed, block the submit and reach onError. The errorSchema passed to onChange, onSubmit and the templates keys those items by index as well, so code that reads such a node as an array (errorSchema.tags.map(...), .length) has to read it by index instead (#3827).

extraErrors now blocks submission by default BREAKING CHANGE​

FormProps.extraErrorsBlockSubmit has been replaced with FormProps.extraErrorsAreWarnings, and the default behavior is inverted: extraErrors now block form submission by default, the same as JSON Schema validation errors.

If you were relying on the old default (extraErrors displayed but non-blocking), set extraErrorsAreWarnings to true. If you were setting extraErrorsBlockSubmit to true to get blocking behavior, remove the prop entirely — that is now the default.

If your app sets extraErrors from a failed server response (the async validation pattern above) and leaves it populated afterward, resubmitting the form will now re-block on those same stale errors and call onError again, since every submit re-merges whatever extraErrors is currently set to. Clear extraErrors (e.g. back to {}) when the user edits the form or before they resubmit, the same way you'd clear a schema validation error.

Removal of the experimental_/Experimental_ prefix BREAKING CHANGE​

The features below were marked experimental while their API stabilized. They are no longer experimental in v7, and the prefix has been dropped from their names:

Old nameNew name
FormProps.experimental_defaultFormStateBehaviorFormProps.defaultFormStateBehavior
FormProps.experimental_customMergeAllOfFormProps.customMergeAllOf
Experimental_DefaultFormStateBehavior (type, @rjsf/utils)DefaultFormStateBehavior
Experimental_ArrayMinItems (type, @rjsf/utils)ArrayMinItems
Experimental_CustomMergeAllOf (type, @rjsf/utils)CustomMergeAllOf

The corresponding experimental_defaultFormStateBehavior/experimental_customMergeAllOf parameters on the @rjsf/utils schema functions (e.g. getDefaultFormState, retrieveSchema, createSchemaUtils, and others) were renamed the same way, and are now members of the SchemaContext those functions take; see @rjsf/utils schema functions take a SchemaContext in place of the validator. Update any code that names these props, types, or parameters explicitly.

time/date-time formats now require a timezone offset BREAKING CHANGE​

@rjsf/validator-ajv8 now depends on ajv-formats 3.x instead of 2.x. Per RFC 3339, ajv-formats 3 requires the JSON Schema time format to include a timezone offset (Z, or +HH:MM/-HH:MM); previously the offset was optional for time alone. date-time already required an offset in ajv-formats 2.x, so it's unaffected by this change. This closes #3930, where format: "time" values produced by RJSF were not spec-compliant.

  • @rjsf/core's TimeWidget (used for format: "time", and the equivalent TimeWidget in @rjsf/daisyui and @rjsf/mantine) now appends the browser's local UTC offset to the value whenever it's changed, e.g. 14:30:00-04:00 instead of 14:30:00. The offset is stripped back off when displaying a value in the native time input. format: "date-time"/alt-datetime were already emitting Z-suffixed UTC strings and are unaffected.
  • If you have previously-stored formData for a format: "time" field that lacks a timezone offset, it will now fail validation. Options:
    • Let the widget resave the value the next time a user edits that field — it will pick up the offset automatically. Note this reinterprets the wall-clock value in the browser's current local offset, so if the stored value already carried a different offset (e.g. entered in another timezone), resaving it will represent a different instant rather than preserving the original one.
    • Backfill stored data with an explicit offset (e.g. treat legacy values as UTC and append Z).
    • If you need to keep accepting offset-less values for a given field, change that field's schema to format: "iso-time"/format: "iso-date-time" instead of format: "time"/format: "date-time" — ajv-formats 3 added these as an explicit, optional-timezone escape hatch for validation. RJSF maps both to the same widget used for time/date-time (TimeWidget/DateTimeWidget, including the dedicated DateTimeWidgets in @rjsf/antd and @rjsf/daisyui), and doesn't add a timezone offset for those formats, so committed values keep behaving like format: "time"/format: "date-time" did before this release. A stored value that happens to carry an offset anyway is still stripped for display, since the underlying date/time picker can't render one either way.

UiSchema<T> checks field names BREAKING CHANGE​

UiSchema<T> used to intersect GenericObjectType (Record<string, any>), so any string key was accepted. The per-field entries are now a mapped type over T, which means a field name that isn't in the form data is a compile error:

type FormData = { firstName: string; address: { city: string } };

const uiSchema: UiSchema<FormData> = {
firstName: { 'ui:widget': 'textarea' },
address: { city: { 'ui:placeholder': 'City' } },
lastName: { 'ui:widget': 'text' }, // error: `lastName` is not a field of FormData
};

FormProps already infers T from formData, so a form with typed form data gets this without naming the type anywhere.

What has not changed:

  • Untyped uiSchemas. UiSchema with no type argument, or with any form data, accepts the same keys as before — arbitrary nesting included. Reading a nested entry back gives unknown, and with no type argument a function under additionalProperties gets unknown data; see any members of the public types are specific types.
  • The ui: namespace. Any ui:-prefixed key is still accepted at every level, so theme-specific and application-specific directives keep working, except on an object whose data is a Record<string, V> with a V that can't hold every value, such as Record<string, string>: its uiSchema reads every key as an entry of the data, so it rejects the ui: keys. Form infers T from initialFormData, so <Form initialFormData={data} uiSchema={{ 'ui:order': ['a'] }} /> with data: Record<string, string> is a TS2322 error. Give the form the wider type, <Form<Record<string, unknown>> initialFormData={data} uiSchema={{ 'ui:order': ['a'] }} />, or the same for that object in a larger T.
  • oneOf/anyOf fields. A union-typed field accepts the keys of every branch, since which branch is present depends on the current value.

What to fix, if any of it affects you:

  • A uiSchema that names a field absent from T — a property added by a dependencies subschema, or an additionalProperties key when T has no index signature. Declare the field in T, as an optional property where it isn't always there, or nest the entry under the additionalProperties key described below. An index signature of unknown values ({ [key: string]: unknown } on the relevant object) also compiles, but it turns off the checking of that object's entries, the declared fields included, so a typo such as name: { 'ui:wigdet': 'textarea' } is no longer caught.
  • Keys the old index signature accepted silently are now declared, with types: additionalProperties (a uiSchema for the data the index signature of T holds, or for unknown data when T has none), additionalItems (a uiSchema for the array's element type), anyOf and oneOf (positional lists, one uiSchema per subschema), and the legacy unprefixed classNames (a string; ui:classNames is the prefixed spelling of the same thing). If you passed an object where a positional list is expected — oneOf: { 'ui:title': '...' } — it was already ignored at runtime, with a console warning; it is now also a type error.
  • A form data field named classNames, anyOf or oneOf cannot be given a per-field entry, because the entry's type is intersected with the declared key's type and a string or a list has nothing in common with a uiSchema. This makes a runtime limitation visible rather than adding one: SchemaField consumes and deletes classNames from every uiSchema it receives, and anyOf/oneOf are read as positional lists wherever the schema has that keyword, so such a field's uiSchema was never reliably delivered. Rename the field, or configure it through ui:options on the parent. A field named items, additionalItems or additionalProperties is fine: those keys hold a uiSchema, so the intersection accepts the per-field entry.
  • A self-referential form data type (type TreeNode = { children: TreeNode[] }) nested far enough in one uiSchema literal can exhaust the type checker (Excessive stack depth comparing types), since the per-field entries recurse with the data. Annotate the deep sub-entry as plain UiSchema to stop the recursion there.
  • A uiSchema typed by an interface you declared is rejected, because an interface has no index signature and UiSchema requires one for its ui: keys. This is not new — the ui:${string} signature predates this change — but the field-name checking makes it easier to hit. Use a type alias, or annotate the literal with UiSchema<T> directly.

ui:options types the options RJSF's widgets read BREAKING CHANGE​

UiSchema and UIOptionsType now declare eight options that RJSF's widgets read, each with the type its readers use, in both the ui:options and ui:<name> spellings. They were any before.

OptionType
acceptstring
expandableboolean
format'DMY' | 'MDY' | 'YMD'
hideClearButton, hideNowButtonboolean
shape'star' | 'heart'
starsnumber
yearsRangereadonly [number, number]

Nothing changes at runtime. What to fix, if the compiler flags it:

  • A custom widget that uses one of these names with another type, such as ui:format: { ... }, no longer compiles, and reading options.format gives the type above. Rename your option:

    - 'ui:options': { format: { currency: 'USD' } },
    + 'ui:options': { moneyFormat: { currency: 'USD' } },
  • A uiSchema held in an unannotated variable infers yearsRange: [2000, 2030] as number[] and shape: 'heart' as string, which no longer fit. Annotate the variable, or mark those values as const. Marking the whole uiSchema as const also makes arrays such as ui:order and ui:enumNames read-only, which don't fit:

    - const uiSchema = { 'ui:options': { yearsRange: [2000, 2030] } };
    + const uiSchema: UiSchema = { 'ui:options': { yearsRange: [2000, 2030] } };
  • A custom alt-date widget that passes on a date element's range or options.yearsRange, both now readonly [number, number], no longer compiles against a parameter typed [number, number]. Mark the parameter readonly, or destructure the range as @rjsf/mantine's AltDateWidget does:

    - function yearOptions(range: [number, number]) {
    + function yearOptions(range: readonly [number, number]) {

RatingWidget stars run from minimum to maximum BREAKING CHANGE​

RatingWidget, in @rjsf/core, which every theme but daisyui renders, and in @rjsf/daisyui's own copy, read maximum as a count of stars and ignored stars when maximum was set. It now reads maximum as the last value a star can have, and stars, still 5 by default and at most 5, as the most stars it draws. Fields like these draw different stars:

Schema and optionsStars beforeStars now
maximum: 30, 1, 20, 1, 2, 3
minimum: 3, maximum: 53, 4, 5, 6, 73, 4, 5
maximum: 00, 1, 2, 3, 40
minimum: 1, maximum: 5, stars: 31, 2, 3, 4, 51, 2, 3

To keep the stars you had, set the bounds to the values you want drawn, and drop a stars option smaller than the range:

- { type: 'integer', maximum: 3 }
+ { type: 'integer', minimum: 0, maximum: 2 }
- 'ui:options': { stars: 3 },

For maximum: 3, lowering the bound also rejects a stored 3. To keep that value valid, leave the schema as it is and cap the stars instead:

+ 'ui:options': { stars: 3 },

UiSchemaDefinitions drops its T parameter BREAKING CHANGE​

UiSchemaDefinitions<S, F> no longer takes the form data type, and types every entry as UiSchema<unknown, S, F>. UiSchema's own ui:definitions key uses the same type. A definition applies to whichever field references it, so typing it by the root form's data rejected the nested keys of the field it describes.

-const definitions: UiSchemaDefinitions<MyFormData, RJSFSchema, MyContext> = { ... };
+const definitions: UiSchemaDefinitions<RJSFSchema, MyContext> = { ... };

A leftover three-argument UiSchemaDefinitions<MyFormData, RJSFSchema, MyContext> fails with TS2707 (too many type arguments). A one- or two-argument UiSchemaDefinitions<MyFormData> or UiSchemaDefinitions<MyFormData, RJSFSchema> binds MyFormData to the schema type S: it may compile, or fail with a TS2559 that doesn't mention the dropped parameter. Drop the form data argument in every case.

@rjsf/mantine's onChangeOverride now receives the change event BREAKING CHANGE​

@rjsf/mantine's BaseInputTemplate routed onChange and onChangeOverride through a single variable and handed the value to both, so an onChangeOverride received the input's string value. Every other theme, and @rjsf/core's own BaseInputTemplate, pass it the raw ChangeEvent — which is also what WidgetProps['onChangeOverride'] has always declared.

onChangeOverride now receives the ChangeEvent with this theme too. If you pass one to a @rjsf/mantine widget, read the value off the event:

// Before
const onChangeOverride = (value: string) => setSomething(value);

// After
const onChangeOverride = (event: ChangeEvent<HTMLInputElement>) => setSomething(event.target.value);

A numeric field with an onChangeOverride now renders Mantine's TextInput rather than its NumberInput, which reports only the parsed value and never an event. The input keeps type="number" (@rjsf/core's <input> no longer does, see below); the override is what handles the input in that case, so the stepper controls and number formatting it replaces have nothing to drive.

Note that @rjsf/core applies ui:emptyValue for you when the input is cleared; an onChangeOverride bypasses onChange entirely, so handle an empty string yourself if you need that behaviour.

@rjsf/mantine's SelectWidget keeps its value under a schema default BREAKING CHANGE​

A single SelectWidget whose schema has a default no longer clears its value when its selected option is clicked again, as the other themes leave out their empty option under a default, so an anyOf/oneOf field's option selector can't be cleared to no option and drop the field's data (#5527). To let such a field be cleared, set allowDeselect or clearable in its ui:options.

@rjsf/mantine's ColorWidget reads colorFormat instead of format BREAKING CHANGE​

ColorWidget used to read ui:options.format for ColorInput's format, the name the alt-date widgets read for their order of date parts. It now reads colorFormat, and format has no effect on it. Without a colorFormat, ColorInput uses Mantine's default or your theme's.

A typed uiSchema stops compiling, since format only takes a date order. An untyped one, such as a uiSchema loaded as JSON, gives no error and the color silently goes back to that default, so search for it there too, in both its ui:options.format and ui:format spellings:

color: {
- 'ui:options': { format: 'rgba' },
+ 'ui:options': { colorFormat: 'rgba' },
},

@rjsf/mantine's AltDateWidget renders its date parts through SelectWidget BREAKING CHANGE​

Each date part of @rjsf/mantine's AltDateWidget (and AltDateTimeWidget) is rendered by the registry's SelectWidget, through DateElement, as in the other themes, rather than by a Mantine Select of its own (#5503). A replacement SelectWidget now renders the date parts too, so it must name and describe them as the other themes' parts need. As in the other themes, a part is no longer required when its field is, since a native select that is required blocks the submit of a date picked only in part. Each part is handed the options of Mantine's Select, searchable, allowDeselect, size, success, inputContainer and wrapperProps, beside the part's enumOptions, so one that spreads its options onto an element should leave those out. Each part's hidden input, which a native form submission reads, holds the index of the picked option, as SelectWidget encodes it, rather than its number, as in the other themes: January submits root__month=0 rather than root__month=1. Read the date from rjsf's formData instead. A month, day, hour, minute or second shows two digits, as in the other themes, so a test that picks or reads a part's option by its text changes:

-await user.click(screen.getByRole('option', { name: '1' }));
+await user.click(screen.getByRole('option', { name: '01' }));

@rjsf/antd's onChangeOverride is now called for a numeric field BREAKING CHANGE​

@rjsf/antd's BaseInputTemplate wired its InputNumber branch straight to onChange. InputNumber reports only the parsed value and never an event, so an onChangeOverride on a numeric field was never called at all.

A numeric field with an onChangeOverride now renders the Input branch (still type="number") so the override receives the ChangeEvent, as WidgetProps['onChangeOverride'] declares and as every other theme does. That field loses InputNumber's stepper controls and formatting, which the override replaces anyway; a numeric field without an onChangeOverride still renders InputNumber as before.

@rjsf/antd's GridTemplate reads a column's span as twelfths BREAKING CHANGE​

A layout grid's span now counts twelfths of a row in every theme, so @rjsf/antd's GridTemplate doubles a column's span into antd's 24 columns rather than handing it to Col as is. A ui:col or ui:columns written with antd's span now spans twice as much: span: 12, half a row before, is a whole row. Halve each span; an odd number becomes a half step, which antd's 24 columns still lay out exactly:

'ui:row': {
children: [
- { 'ui:col': { span: 9, children: ['address'] } },
- { 'ui:col': { span: 15, children: ['employment'] } },
+ { 'ui:col': { span: 4.5, children: ['address'] } },
+ { 'ui:col': { span: 7.5, children: ['employment'] } },
],
},

A column whose span is more than 12, which only an unmigrated one can be, logs a warning saying to halve it. One of 12 or under can't be told apart from a migrated one, so check those yourself. A column with a flex prop or a flex, flexBasis or width style of its own now keeps that width and drops its span, logging a warning that the span is ignored, where before Col was handed both. antd's breakpoint props, such as xs or md, still take antd's 24ths. See span in each theme.

@rjsf/antd starts an object and an array heading, and labelAlign aligns field labels too BREAKING CHANGE​

This affects a form that renders an object or an array with a title through @rjsf/antd, and a form that sets formContext.labelAlign. ObjectFieldTemplate and ArrayFieldTemplate ended the heading of an object or an array by default, away from the fields it heads, whose labels sit above their controls at the start. The heading now starts its column, on the same side as those labels.

A form whose formContext.labelCol has a span and an xl that aren't 24, such as { span: 8 } for a horizontal form, and that sets no labelAlign, keeps its headings at the end: antd ends the labels of a column that narrow, and the headings follow them. On a screen up to screenXSMax wide, 575px by default, antd starts every field label whatever labelCol says, so there such a form keeps its headings at the end beside labels at the start.

labelAlign now reaches the labels too: the theme passes it to the Form.Item of every field and of an additional property's key, so 'start' and 'left' start them and 'end' and 'right' end them, where it used to move the headings only. It doesn't align the text of a CheckboxWidget, which the widget draws beside its box. In a standard horizontal form, where labelCol is narrower than 24 columns such as { span: 8 }, a labelAlign of 'start' or 'left' now starts the labels, which antd ends by default; 'end' and 'right' change nothing there. antd starts the label of a 24-column labelCol whatever labelAlign says, so under the default labelCol labelAlign moves the headings only. antd 6 logs a development warning for 'left' and 'right', which it deprecates for 'start' and 'end'. @rjsf/antd now needs antd 6.7 or newer, the first release to understand 'start' and 'end'. The theme reads labelAlign and labelCol from formContext for a heading, which can't see the labelAlign, labelCol or layout of an enclosing antd Form. A field's label gets the labelAlign and layout of an enclosing Form through antd's context, but not its labelCol, which the theme replaces with formContext.labelCol, { span: 24 } by default.

To keep a heading at the end in a form without such a labelCol, set labelAlign to 'end'. Under a labelCol of 24 columns this ends the headings but not the labels, which antd starts there, so it brings back the old mismatch between a heading and the label of its fields:

-<Form schema={schema} validator={validator} />
+<Form schema={schema} validator={validator} formContext={{ labelAlign: 'end' }} />

A heading's column also gains the ant-form-item-label-start and ant-form-item-label-left classes and loses antd's end alignment, so a test or stylesheet that expected text-align: end on it, or that selected the heading's column without those classes, needs the same labelAlign.

A layout grid row without a column template lays out on 12 tracks in chakra-ui, fluentui-rc and shadcn BREAKING CHANGE​

So that a column's span lays out the same in every theme, a ui:row in @rjsf/chakra-ui, @rjsf/fluentui-rc or @rjsf/shadcn that has no column template of its own (templateColumns, templateAreas, columns, a gridTemplateColumns, gridTemplateAreas, gridTemplate or grid style, the same keys in chakra-ui's css, or a grid-cols-* class) now lays out on 12 tracks, and each child is full width unless it spans fewer. A row that relied on the grid placing its children by itself, such as a chakra-ui row with autoFlow: 'column', a fluentui-rc row with rows but no columns, or a shadcn row with grid-flow-col, now stacks them. Give such a row its template to keep the old layout:

'ui:row': {
+ templateColumns: 'repeat(3, 1fr)',
autoFlow: 'column',
children: ['first', 'middle', 'last'],
},

A custom GridTemplate is handed a fieldId BREAKING CHANGE​

LayoutGridField now hands every GridTemplate a fieldId, so a theme can name the column in the warnings it logs about a span. A column gets the id of the first field it holds; a row, or a column holding no field of its own, gets the id of the field whose layout grid holds it. A custom GridTemplate that spreads its props onto an element would put fieldId on it, and React warns about the unknown prop. Take it off first:

function GridTemplate(props: GridTemplateProps) {
- const { children, column, ...rest } = props;
+ const { children, column, fieldId, ...rest } = props;
return <div {...rest}>{children}</div>;
}

To log the same span warnings as the built-in themes, hand fieldId to logGridSpanWarning() along with the column's span.

A row's span no longer reaches GridTemplate either: it only ever sized a column, and LayoutGridField now warns about it on a row and keeps it back.

Field identity is a FieldPath string instead of a FieldPathId object BREAKING CHANGE​

Every field, template and widget used to receive a fieldPathId prop, an object { $id, path, name? } rebuilt on every render. Because it was a new object each time, it defeated React.memo at every level and forced deep comparison of props to compensate. Field identity is now two primitive strings:

  • fieldPath: a FieldPath such as friends[0].firstName, telling where the data lives. It drives onChange writes. Property names are separated by ., array indexes are bracketed, and \ . [ ] inside a property name are backslash-escaped, so it stays unambiguous when property names contain dots or brackets. The root form is the empty string, exported as ROOT_FIELD_PATH.
  • id: the DOM id such as root_friends_0_firstName, telling what the element is called. It drives htmlFor, focus and accessibility attributes. Some ids differ from v6, as Ids can't collide or split an id list describes. The optional-data controls (enableOptionalDataFieldForType) of an object with properties of its own or room for more keys beside its oneOf/anyOf take the id of the component rendered beside the options, and the selected option draws none, where v6 drew a second set for it under the same DOM id.

FieldProps and FieldTemplateProps receive both. Every other template that received fieldPathId (ArrayFieldTemplate, ArrayFieldTitleTemplate, ArrayFieldDescriptionTemplate, ArrayFieldItemButtonsTemplate, CyclicSchemaExpandTemplate, FieldErrorTemplate, FieldHelpTemplate, ObjectFieldTemplate, UnsupportedFieldTemplate) now receives id instead. The childFieldPathId prop on ObjectField/ArrayField is gone, since fieldPath is always a truthful data address. IChangeEvent and FormState no longer carry fieldPathId either, so an onChange(e)/onSubmit(e) handler that read e.fieldPathId must derive what it needs some other way — the root form's id is idPrefix, and e.schemaUtils, e.formData and e.errorSchema are unchanged.

-const { fieldPathId } = props;
-<label htmlFor={fieldPathId.$id}>
-<h2 id={titleId(fieldPathId)}>
+const { id } = props;
+<label htmlFor={id}>
+<h2 id={titleId(id)}>

The id helpers titleId(), descriptionId(), helpId(), errorId(), examplesId(), buttonId() and optionalControlsId() accept only a string id.

FieldProps['onChange'] carries the FieldPath string instead of a FieldPathList; fields forward the fieldPath prop they already hold:

-onChange(newValue, fieldPathId.path, ownErrors, fieldPathId.$id);
+onChange(newValue, fieldPath, ownErrors, id);

ownErrors stands for the errors the field itself raises, not the errorSchema prop it was given: see "The errorSchema passed to onChange holds only the field's own errors" below.

toFieldPathId() and the FieldPathId type are removed. Build paths with toFieldPath(segment, parentPath?) and derive what you need from them:

WasNow
toFieldPathId(name, options, parent)toFieldPath(name, parentFieldPath)
fieldPathId.$idid prop, or fieldPathToId(fieldPath, options)
fieldPathId.pathfieldPathToList(fieldPath)
fieldPathId.namefieldPathToName(fieldPath, options, isMulti)

FieldPath is a branded string, so a plain string (a DOM id, for example) is rejected by the compiler where a FieldPath is expected. A FieldPath is still assignable wherever a string is accepted.

The useDeepCompareMemo() hook is removed. It existed to hand out a stable fieldPathId object across renders; with two primitive strings there is nothing to stabilize. A custom field or widget that used it for its own props can use useMemo(), or compare the values it cares about directly.

Ids can't collide or split an id list BREAKING CHANGE​

Every id RJSF renders is built by @rjsf/utils: a field's own id by fieldPathToId(), and the id of an element that is part of a field, such as its description, a date part or the key input of an additional property, by partId(), which joins the field's id and the part's name with __. fieldPathToId() never writes a __ into a field's own id, so the id of one field's part can't be another field's id, it writes no whitespace, so a field id stays one id in aria-describedby, aria-labelledby and aria-controls (#5558), and every id is a CSS identifier as it stands, by the CSS Syntax 3 rules browsers ship, so #${id} selects the element in querySelector() and in a stylesheet with no escaping.

A property name is written into an id as it is, except for an escape. A character is written as an escape, -_ followed by the two uppercase hex digits of its code, when it is an ASCII character other than a letter, a digit, - or _, such as a space, ., :, /, #, [ or %; when it is an _ that begins or ends the name, stands next to another _ or follows a -; or when it is a - that ends the name. An empty name is written -_FF. Every other character, an _ or - inside a name such as snake_case or kebab-case and any non-ASCII letter included, is written as it is. The encoder writes -_ nowhere else, so every -_ starts an escape and two names never write the same id. A name such as firstName, first-name, snake_case_name or prénom keeps its v6 id.

These ids change:

Elementv6v7
Field whose property name holds whitespace, such as first nameroot_first nameroot_first-_20name
Field whose property name holds another character a CSS identifier can't hold unescaped, such as a.b, a:b or 100%root_a.b, root_a:b, root_100%root_a-_2Eb, root_a-_3Ab, root_100-_25
Field whose property name begins or ends with _, holds __ or -_, or ends with -, such as _id, __typename or a-root__id, root___typename, root_a-root_-_5Fid, root_-_5F-_5Ftypename, root_a-_2D
Field whose property name is ''root_root_-_FF
Date part of an AltDateWidget (#5504)root_when_yearroot_when__year
Radio or checkboxes option, from optionId()root_color-0root_color__0
Key input of an additional property, from the new additionalKeyId()root_foo-keyroot_foo__key
Expand button of a cyclic schema, from expandButtonId()root_child-buttonroot_child__expand
Component rendered beside anyOf/oneOf options, from the new besideOptionsId()root_pet_XxxOfroot_pet__XxxOf, and root_pet__XxxOf2 beside the options of an option that is itself an anyOf/oneOf
Option selector of an anyOf/oneOf option that is itself an anyOf/oneOf, from the new optionSelectorId() (#5574)root_pet__anyof_select, the same id as the selector above itroot_pet__anyof_select2
Type selector of the fallback UI for an unsupported type, from the new fallbackTypeSelectorId()root_x___internal_type_selector, and root.x.__internal_type_selector under idSeparator: '.'root_x__internalTypeSelector, root_x__internalTypeSelector2 for an option offering types of its own, and root.x__internalTypeSelector under idSeparator: '.'
@rjsf/daisyui's time input in the date-time picker popuproot_when-pickerroot_when__time
@rjsf/mantine's success messageroot_when-successroot_when__success
@rjsf/mantine's label and description of an input, an additional property's key input includedroot_when-label, root_when-description, root_foo-key-labelroot_when__wrapperLabel, root_when__wrapperDescription, root_foo__key__wrapperLabel
@rjsf/mui's label of a text input, a select or an additional property's key inputroot_when-label, root_foo-key-labelroot_when__inputLabel, root_foo__key__inputLabel
@rjsf/mui's helper text of a text input or select, from helperText in ui:options.muiroot_when-helper-textroot_when__helperText

Every field's control takes its id as its name too, unless the form sets a nameGenerator, so a field in the first four rows above posts under its new id, such as root_first-_20name rather than root_first name, and FormData.get('root_first name'), or server code reading the v6 name, gets null. A date part's name is its id, and so is the name of an additional property's key input in @rjsf/antd, @rjsf/chakra-ui, @rjsf/mantine and @rjsf/mui (root_foo-key in v6) and of a nested option selector, so each posts under its new id too, and code reading it from FormData or form.elements.namedItem(), or a test finding it by [name=...], should use the new id. A selector or test aimed at one of the v6 ids should aim at the v7 id, which #${id} selects as it is, such as #root_first-_20name. A custom widget or template rendering an element of its own beside a field's control, such as a popup or a second input, should take its id from partId(id, '<name>') rather than appending a suffix of its own, so it can't take another field's id either. Focus-on-error relies on it too: where no element has a field's id, it focuses the first input or button whose id is a part of the field's, so a widget whose controls take ids such as ${id}-0 is no longer focused for its field's error. One id is still derived by a library rather than built by RJSF: @rjsf/mantine's Select and MultiSelect name their listbox by the ${id}-label id Mantine gives their label, so that label keeps it, and a sibling property named such as color-label writes the same id.

The idPrefix and idSeparator are written into ids as given, so to keep ids CSS identifiers holding no __, each should hold only letters, digits, - and _, with no __ in either or where they meet. A property name holding the idSeparator still writes the id a path of several names would, as the name a_b and the path a.b, a property b of a property a, both write root_a_b, as in v6; choose an idSeparator no property name holds to tell those apart.

IChangeEvent members are read-only BREAKING CHANGE​

IChangeEvent, the event onChange and onSubmit receive, is now declared on its own instead of as a Pick of the Form's internal state, and every member is readonly. The members and their types are unchanged, except two. formData is now EventFormData<T>; see Generic defaults are unknown instead of any. And edit is gone: it only restated whether you had passed data, so check your props instead. Readonly modifiers do not affect assignability, so a handler that reads the event or passes it on compiles as before; only code that assigns into the event breaks:

// Before: compiled, and wrote into data the form shares with its parent
onChange={(event) => { event.formData = normalize(event.formData); setData(event.formData); }}

// After: copy out of the event into your own state
onChange={(event) => setData(normalize(event.formData))}

The modifier is shallow: it stops event.formData = ..., not a write inside formData. Treat the whole event as immutable, as the guide says for received form data above.

IChangeEvent has required dirty, dirtyFields, touched, touchedFields and submitCount, and FormRef a required getStatus() BREAKING CHANGE​

IChangeEvent, the event onChange and onSubmit receive, has five new required members, the form's status: dirty, dirtyFields, touched, touchedFields and submitCount (see Unsaved changes and Touched fields). FormRef has a new required method, getStatus(), which returns the same five.

This is only a breaking change if you build an IChangeEvent or a FormRef yourself, such as a wrapper component calling an onChange prop, a test fixture or a test double; code that only reads events or calls the handle is unaffected. Such an object fails to compile until it adds them:

-const event: IChangeEvent<MyData> = { ...rest };
+const event: IChangeEvent<MyData> = {
+ ...rest,
+ dirty: false,
+ dirtyFields: {},
+ touched: false,
+ touchedFields: {},
+ submitCount: 0,
+};

A stand-in for a FormRef likewise needs a getStatus() method returning { dirty, dirtyFields, touched, touchedFields, submitCount }.

ui:emptyValue extended scope BREAKING CHANGE​

ui:emptyValue used to only supply a value when a user actively cleared a field's widget. It now applies whenever the field is blank for any reason — on initial render, after a form reset, or when cleared — since getDefaultFormState() threads uiSchema through its defaults pipeline (computeDefaults()/getObjectDefaults()) to pick it up.

If a field has no formData and no schema.default, and you set ui:emptyValue for it, that field's formData will now be populated with the ui:emptyValue on first render and after a reset, instead of staying undefined (and being omitted from formData entirely) until the user clears it once. See ui:emptyValue.

Shallow comparison replaces experimental_componentUpdateStrategy BREAKING CHANGE​

Form and SchemaField now compare their props shallowly, as React.memo does, and keep the references of unchanged data across changes so that comparison is enough. The experimental_componentUpdateStrategy prop is removed, and with it shouldRender(), shallowEquals() and the ComponentUpdateStrategy type from @rjsf/utils.

What to change:

  • Remove the experimental_componentUpdateStrategy prop from <Form> and from any globalFormOptions read of it.
  • Give Form stable references for props that hold functions: customValidate, transformErrors, translateString, nameGenerator, customMergeAllOf, validator, fields, widgets, templates, formContext, and a uiSchema carrying components such as ui:widget. A value recreated on every render re-derives the form's state on every render, so wrap it in useCallback or useMemo, or define it outside the render function. A changed one now takes effect at once, where v6 ignored it until another prop changed; with liveValidate: 'onChange', a changed validator, customValidate, transformErrors, customMergeAllOf or schema also re-validates the current data. That re-validation, and the one setFieldValue('', data) triggers, validates with the schema resolved for the data whenever the schema utilities are unchanged (the same schema, validator, defaultFormStateBehavior and customMergeAllOf), the way an edit is validated; a replaced value shows the same live errors typing it would, and a miss that only the root schema reports (if/then, oneOf) shows on submit, as it does for typed data. uiSchema and formContext take effect in rendering at once, but a change to either re-validates only on the next data change, blur or submit, which matters if ui:required depends on state outside the form.
  • Treat the formData you receive from onChange, onBlur, onSubmit and the other callbacks as immutable. It shares unchanged subtrees with the previous value, the formData you passed included, so mutating it in place corrupts your own state. Copy before changing, as with any reducer.
  • Treat schemas returned by retrieveSchema() as immutable; it may return the same instance across calls.
  • Replace shouldRender(this, nextProps, nextState) in a class component's shouldComponentUpdate by extending PureComponent, which is the same comparison.
  • Replace shallowEquals with shallowEqual from fast-equals.

A type list follows its ui:widget to the field and class it renders BREAKING CHANGE​

This affects a schema whose type is a list and whose ui:widget belongs to another type it lists.

A list resolving to object or array used to ignore a ui:widget such as textarea: ['null', 'object', 'string'] rendered ObjectField, and ['array', 'string'] threw No widget 'textarea' for type 'array'. It now renders the field of the type that has the widget, here StringField, and fills in no defaults for the object's properties. A value that is already an object or an array stays in its own field, which ignores the widget. To keep the object or array field, remove the ui:widget:

const uiSchema = {
- note: { 'ui:widget': 'textarea' },
};

resolveWidget() follows the same rule, so it returns TextareaWidget for those schemas rather than throwing.

The rjsf-field-<type> class now names the field rendered rather than the type the list resolves to: a textarea on a ['null', 'number', 'string'] is rjsf-field-string and a select over ['null', 'boolean', 'string'] is rjsf-field-string, where both were named for their first non-null type. Update a CSS rule or test that matches the old class.

A typeless oneOf/anyOf of constants now renders as a select BREAKING CHANGE​

A oneOf/anyOf whose options are all constants (const or a single-value enum) used to render as an unsupported field when the schema omitted type, because neither a field nor a widget could be resolved without one. SchemaField now infers the type from the constant values, so the select these schemas always described actually renders (#4666):

{
"oneOf": [
{ "const": "gpl", "title": "GPL" },
{ "const": "mit", "title": "MIT" }
]
}

That includes object and array constants, which render through a select whose options are labelled with their title, or with their JSON when they have none, whether the schema omits type or declares type: 'object'/'array' (#5317). Object and array constants are given the type object/array, and a declared type is kept as declared, so custom fields, templates and widgets see the type of the value they choose. The select is rendered by StringField, which renders every other select, rather than by ObjectField/ArrayField, which edit a value's contents. An empty oneOf: []/anyOf: [] offers nothing to choose, so a typed object or array with one is still edited through ObjectField/ArrayField. getDefaultFormState() fills such a select as a whole too, the way it fills a primitive select: a required oneOf/anyOf select defaults to the constant its own default names, or else its first, honoring constAsDefaults (including skipOneOf), a required enum select is left unfilled, and a value already picked is kept as it is. An ancestor's default naming one of its constants picks that one, and ui:initialValue/ui:emptyValue apply to it as they do to a primitive select. Both used to get an empty {}/[] that no option allows, and a picked object could be blended with the select's default. A typed object/array whose constants are spelled as an enum ({ "type": "object", "enum": [{ "a": 1 }, { "a": 2 }] }) renders the same select, where it used to render an ObjectField with no control or an ArrayField list editor. omitExtraData(), sanitizeDataForNewSchema() and the ui:required check treat such a select's value as one constant rather than a container, so omitExtraData/liveOmit keep the picked object or array whole instead of pruning it to nothing, and switching to a schema whose select doesn't offer it clears it. Picking one sanitizes the data a condition on it controls, as picking a primitive option does, where it used to leave data only the old branch allowed. An array select offers the radio alias as well as select and hidden, and no longer the checkboxes or files aliases, which read the picked array as a list of selections; a multi-select array still has no radio. A select, spelled as an enum or a oneOf/anyOf of constants, whose type list names more than one non-null type renders through StringField, which keeps each option's value as it is, instead of through the field for the first listed type, which rendered NullField for a list starting with null and cast every option's value to a number for a list starting with number (so a true option wrote 1).

Most forms only gain the field they were already asking for. Three narrower shapes change behavior, and each is worth checking:

A schema with both anyOf and oneOf. optionsList() and the option selector read anyOf before oneOf, but isSelect(), sanitizeDataForNewSchema(), BooleanField, the LayoutGridField path (findFieldInSchema(), LayoutMultiSchemaField), the defaults getDefaultFormState() computes, the ui:required check, retrieveSchema()'s option resolution and getClosestMatchingOption()'s scoring of nested properties read oneOf first, so a schema carrying both keywords could render one list while deciding it was a select from the other — or throw schema cannot be inferred as a constant during render (#5309). Every one of them now reads anyOf first, through the new getXxxOfKey() utility (an empty anyOf: [] gives way to a non-empty oneOf), so a constant default comes from the anyOf options on screen and a ui:required field inside the rendered anyOf option is reported. The option selector follows: its id suffix is __anyof_select, where it used to say __oneof_select whenever a oneOf was present, and its options take their labels and per-option uiSchema from uiSchema.anyOf, where they used to come from uiSchema.oneOf. A schema with constant options under both keywords renders the anyOf options, and one whose anyOf options aren't all constants renders that anyOf's option selector, where it used to render a select of the oneOf constants. A schema with only one of the two keywords is unaffected.

What to change: give the two keywords the same options, or drop the redundant one.

A type: 'boolean' with labelled constant options. A checkbox shows no option labels, so a boolean whose oneOf/anyOf has more than one constant option, at least one of them labelled by its title or a ui:title in the matching uiSchema.oneOf/uiSchema.anyOf entry, now defaults to a select, the same widget its typeless spelling gets (#5309):

{
"type": "boolean",
"oneOf": [
{ "const": true, "title": "Yes" },
{ "const": false, "title": "No" }
]
}

The same goes for a nullable boolean ("type": ["boolean", "null"]) whose options include a null constant, which a checkbox has no state for, labelled or not. A single option, the usual way to spell a checkbox that must be checked, and unlabelled true/false options keep the checkbox. A custom field chosen by ui:field isn't handed that default: the uiSchema it receives has no ui:widget unless the caller set one. ui:field: 'BooleanField' still gets the select, and so does a field registered under the schema's $id, which keeps its label that way.

What to change: set "ui:widget": "checkbox" to keep the checkbox.

A typeless falsy const with constAsDefaults: 'never'. getSchemaType() returned undefined for a typeless schema whose const was falsy (0, false, '' or null), which is what rendered those as unsupported fields. A typeless { "const": null } now resolves to null and renders through NullField, exactly like an explicit { "type": "null" }.

At the default constAsDefaults setting nothing changes in the emitted data: such a property already contributed its constant as its own default. With defaultFormStateBehavior.constAsDefaults set to 'never', which suppresses that default, a typeless { "const": null } and a required typeless { "const": false } now write null and false into the form data on mount where nothing was written before — the first as the null default the form fills in for a type: 'null' value, the second from the required-boolean default. Each matches what the explicit type spelling already did. Required { "const": 0 } and { "const": "" } are unaffected.

What to change: nothing, if you want the two spellings to agree. If a form relied on those keys staying absent, drop the property from the schema instead of relying on it not rendering.

A boolean enum labels its values Yes/No BREAKING CHANGE​

A type: 'boolean' field labels every true and false in its enum that ui:enumNames doesn't name with the translated Yes/No, as it already labels untitled boolean constants (#5319). The labels are in the enumOptions it passes to whatever widget renders it, so a select, radios or a custom widget that reads options.enumOptions shows them. Only an unnamed two-value enum of true and false read Yes/No before, so "enum": [true] and a nullable "enum": [true, false, null] now read Yes, No and null where they read true, false and null. ui:enumOrder now orders a boolean's Yes/No options, those of a two-value enum and the implicit true and false of a plain { "type": "boolean" }, where it used to ignore them unless ui:enumNames was also set. As it does for any enum, the order drops a value it doesn't list unless it has '*', so "ui:enumOrder": [true] now leaves only Yes where it showed Yes and No, and an empty "ui:enumOrder": [] leaves no options at all. A constant option whose title is empty ("title": "") now has an empty label where it read Yes or No, since BooleanField reads its options through optionsList(), which keeps an empty title for every other type (#4448).

What to change: name the values with ui:enumNames to keep other labels, list both values or add '*' to a ui:enumOrder meant only to put one first, drop an empty ui:enumOrder, and drop an empty title to keep Yes or No. Update tests or DOM snapshots that match the option text true/false.

ui:enumOrder orders constant anyOf/oneOf options BREAKING CHANGE​

ui:enumOrder now orders the options of an anyOf or oneOf made entirely of constants, as it orders enum values, where 6.x ignored it there (#4298). As it does for an enum, the order drops a value it doesn't list unless it has '*', so a "ui:enumOrder": ["b"] left beside a constant oneOf of a, b and c now leaves only b. Options keep their schema order when any of them is read through a discriminator or ui:optionsSchemaSelector.

What to change: list every value, or add '*', to a ui:enumOrder beside constant options, or remove one that was never meant to apply.

EnumValue includes null, objects and arrays BREAKING CHANGE​

EnumValue, the type of the entries of ui:enumOrder, ui:enumDisabled and optgroups, is now string | number | boolean | null | object, where it was string | number | boolean (#5392). Code that writes those lists still compiles. Code that reads them, such as a custom widget reading options.enumDisabled, can now fail to compile where it treats an entry as a primitive.

What to change: handle null, objects and arrays where you read an EnumValue, or narrow it first. Remove a cast you added to list one of those values.

- const disabled = options.enumDisabled?.map((value) => value.toString());
+ const disabled = options.enumDisabled?.map((value) => enumOptionValueLabel(value));

enumOptionValueLabel() spells an object or array out as JSON, where String() would give [object Object] or join an array's items.

number/integer fields no longer render a native <input type="number"> by default BREAKING CHANGE​

A BaseInputTemplate now renders a number/integer field as <input type="text" inputMode="decimal|numeric" pattern="..."> instead of <input type="number">, avoiding the cross-browser keystroke-filtering, scroll-wheel, and locale decimal-separator quirks of a native number input. The pattern still blocks a non-numeric submission the same way required already does, and accepts the exponent notation JavaScript renders very small and very large numbers with. A number accepts either sign of exponent (1e-7, 1e+21); an integer is only ever rendered that way from 1e21 up and always with a +, so a negative exponent is rejected there rather than admitting 1e-7 where 0.0000001 is refused. The integer pattern accepts every spelling of a whole number the field itself produces while you type — a trailing separator and zeros (5., 5.00), and the fractional mantissa of the exponent form (String(2 ** 70) is 1.1805916207174113e+21) — while still refusing an actual fraction such as 1.5. Set ui:options.inputType: 'number', or use the updown widget, to keep a native number input for a field. See #4038.

A schema whose type is an array counts as a number/integer field when exactly one of its entries isn't "null", so ["number", "null"] and ["number"] both render the numeric text input — SchemaField already rendered them through NumberField, and they used to come back as a plain text input carrying min/max/step. A union of more than one non-null type, such as ["number", "string"], keeps the plain text input it had before. A schema with no type at all but a numeric const counts the same way, since that is the type SchemaField picks its field by.

step, min and max only constrain a native number input, so these fields no longer get them, and the browser no longer range- or step-checks the value before submit. minimum, maximum and multipleOf are still enforced by RJSF's own validation, which reports them as form errors. They are still returned when the field renders a native number input, through ui:options.inputType: 'number' or the updown widget, along with step="any" for a number field with no multipleOf so decimals are accepted (the updown widget did not get that step before).

Because the value lives in a text input with a pattern, native constraint validation can now block a submit whose value doesn't match, such as legacy server data like 'yo' loaded into a number field. When that happens onSubmit and onError never fire, and the browser shows its own message on the field instead of an RJSF error. If you want RJSF's validation to be the only validator, set noHtml5Validate on the Form.

A pattern the value fails is reported by the browser as a bare "Please match the requested format", which says nothing on a field that now looks like a plain text box, so RJSF sets a title on the input for the browser to append to that message. The two strings go through translateString as TranslatableString.NumberInputTitle ("Enter a number") and TranslatableString.IntegerInputTitle ("Enter a whole number"); translate them as you do the rest. A BaseInputTemplate of your own that renders a plain <input> takes that title from getBaseInputProps(), which returns it beside the input props, and puts it before them. A pattern of your own replaces the derived one and takes the derived title with it, since that title describes a rule no longer in force: pass it as the pattern prop of BaseInputTemplate from a widget in any theme, through extraProps in shadcn and react-bootstrap, or through slotProps.htmlInput in mui. A title of your own reaches the input as a widget prop in core and daisyui, through extraProps in shadcn and react-bootstrap, or through slotProps.htmlInput in mui; the other themes render a fixed set of props, so it doesn't reach the input in those.

That title is set whenever the pattern is, not only when the value fails it, which has two visible consequences. The browser shows it as a tooltip when the pointer rests on any number/integer field, and because title is the last fallback in accessible-name computation, a numeric field rendered with no label at all (ui:options: { label: false }, or a layout that hides it) is now announced by that title instead of going unnamed — the same name for every such field on the page. Give those fields a ui:title or an aria-label if either matters, or your own pattern, or your own title in the themes listed above that forward it.

A text input also lets a value that isn't a number reach formData: asNumber() keeps the raw string when Number() can't parse it, so a half-typed 1.2.3 or a lone - stays in the number slot, where a native number input reported an empty value instead. Code reading formData from onChange, from getFormData(), or through liveValidate errors should expect a string there while a field is being edited. RJSF's validation reports it as a type error, and the pattern blocks submitting it.

The numeric virtual keyboards that inputMode="decimal" and "numeric" request are also digit-only on some platforms: the iOS ones have no minus key, so a field that accepts negative values can't have one typed on an iPhone or iPad. RJSF doesn't switch inputMode by the schema's minimum, because most number fields have none and would lose the numeric keyboard. For a field that needs negative values on iOS, set ui:options.inputType: 'number' (or use the updown widget), whose keyboard does have a minus key.

Accessibility changes with the element. A native <input type="number"> is exposed as a spinbutton, and min and max give it aria-valuemin/aria-valuemax, so a screen reader announces a { "type": "integer", "minimum": 1, "maximum": 10 } field as "spin button, 1 to 10". A text input with inputMode is a textbox, and inputMode is only a virtual-keyboard hint that assistive technology doesn't see, so the range is no longer announced. RJSF doesn't add role="spinbutton" or aria-valuemin/aria-valuemax to compensate: the role promises arrow-key stepping that a text input doesn't implement, and those attributes aren't valid on a textbox. For a field where the range matters to the user, state it in ui:description or ui:help, which the input is already linked to through aria-describedby, or set ui:options.inputType: 'number' (or use the updown widget) to keep the native semantics.

This only affects themes whose BaseInputTemplate renders a plain native <input>; @rjsf/antd and @rjsf/mantine already render their own InputNumber/NumberInput component and are unaffected, except that the plain input they fall back to when a widget supplies an onChangeOverride is still type="number". If your code selects the widget by its DOM type (input[type="number"], getByRole('spinbutton')), select on inputMode (input[inputmode="decimal"]) or your own field/id instead.

A BaseInputTemplate of your own forwards inputMode and pattern BREAKING CHANGE​

InputPropsType gained optional inputMode and pattern members. If your own BaseInputTemplate spreads the result of getBaseInputProps() onto an <input>, they reach it automatically. If it hands them to a component that doesn't forward unknown props to the native <input>, pull inputMode and pattern out and pass them to it explicitly, as @rjsf/mui does through slotProps.htmlInput; without them the field renders as a plain text input with no numeric keyboard or pattern.

@rjsf/mui now applies the derived input attributes before slotProps.htmlInput, so a step, min, max or accept you pass through slotProps.htmlInput or ui:options.mui.slotProps.htmlInput takes precedence instead of being overwritten by the derived value. If you added one of those to work around the old clobbering — a { "type": "integer", "multipleOf": 5 } field given slotProps={{ htmlInput: { step: 1 } }}, say — it now takes effect, and the browser step-checks against your value rather than the schema's. BaseInputTemplateMuiProps['slotProps']['htmlInput'] is typed as InputHTMLAttributes<HTMLInputElement | HTMLTextAreaElement>, or MUI's (ownerState) => props returning them, so those <input>-only attributes are accepted.

FieldTemplateProps gains a required keyName BREAKING CHANGE​

FieldTemplateProps has a new required keyName: string member, the name of the field's property in its parent object, and WrapIfAdditionalTemplateProps picks it up too. It carries none of the decoration label may have picked up — a ui:title, or the (deprecated) marker the default deprecatedHandling appends — so an additionalProperties key is read and renamed from the real key rather than from what is displayed. Rendering a FieldTemplate does not change; only code that constructs FieldTemplateProps itself is affected, and that code fails to compile with TS2741 until it supplies keyName.

A custom WrapIfAdditionalTemplate should take the key control's value from keyName instead of label, keeping label for the control's own label:

const keyLabel = translateString(TranslatableString.KeyLabel, [label]);
// ...
<input type='text' id={additionalKeyId(id)} onBlur={onKeyRenameBlur} defaultValue={keyName} />;

Without it, a deprecated additional property keyed foo shows foo (deprecated) in its key input and renames itself to that string on blur.

ui:hideError now suppresses the theme's error styling BREAKING CHANGE​

ui:hideError was only ever half honored by the themes. It suppressed the error messages RJSF renders, but the input underneath kept whatever the theme uses to signal invalid — MUI's Mui-error, Bootstrap's is-invalid, Chakra's invalid flag, Fluent's validationState, shadcn's border-destructive — so a field with hidden errors still rendered a red outline with nothing explaining it (#3684). @rjsf/mantine went further: its widgets handed the messages to Mantine's own error prop, and its AltDateWidget and RangeWidget rendered an Input.Error apiece, so a Mantine user saw the error text itself under a directive asking for it to be hidden. Every theme's widgets now respect the directive, so a form using ui:hideError loses that outline — and, on Mantine, the error text that came with it. A field's ui:hideError is resolved as its own, then the one its parent resolved to, so a parent's directive reaches its children; ui:globalOptions holds no hideError, so a ui:hideError at the root of the uiSchema hides every field's errors. The directive also reaches components that were never handed it: the oneOf/anyOf selector MultiSchemaField renders, the widget ArrayField renders for a multi-select array or an array of files, and the ArrayFieldTemplate and ObjectFieldTemplate, whose hideError prop was declared but never set.

@rjsf/chakra-ui's CheckboxesWidget changes in two more ways, since it had a second, unrelated source of error styling:

  • It no longer flags a required field while it is still empty. Its inner CheckboxGroup was marked invalid whenever the field was required and had nothing selected, before validation had found anything wrong and whatever ui:hideError said, so every checkbox carried aria-invalid="true" and data-invalid from the first render. That state is now derived from the field's visible errors like every other error state, and there is no option to bring the old behavior back; a required checkboxes field is flagged once validation reports an error for it, as any other field is.
  • Once the field does have visible errors, every checkbox is marked invalid (aria-invalid="true" and data-invalid), where a field that was not required used to mark only the surrounding fieldset.

This changes nothing about what rawErrors contains. Fields and widgets are still handed the full list while errors are hidden, which is what lets a custom widget render them its own way — that is the whole point of the directive. What changed is that the built-in widgets no longer treat a non-empty rawErrors as reason enough to style themselves as invalid.

If you maintain a custom theme or widget, derive your error state from its field's plan rather than reading rawErrors directly: hasWidgetErrors() reads the aria-invalid the plan gives a widget's control, with ui:hideError applied, falling back to rawErrors with hideError only for a widget rendered without a plan, and a template reads its field's own errors, with ui:hideError applied, from its hasErrors prop:

// Before: renders an error state even when `ui:hideError` is set
const hasError = rawErrors.length > 0;

// After
const hasError = hasWidgetErrors(props);

hasVisibleErrors(), added in this release for the same purpose, is deprecated in favor of the plan, as is VisibleErrorsProps, its parameter type; a widget or container template that draws its own messages reads them from getErrorsToDraw(). See A field's label, description, help and errors are drawn where its plan says.

A custom FieldTemplate needs no such change to its own error state. One that lists the props it forwards to WrapIfAdditionalTemplate one by one, rather than spreading them, adds hasErrors to that list, as it does propertyNamesEnum and keyName: WrapIfAdditionalTemplateProps picks it as a required prop, and core's WrapIfAdditionalTemplate takes its has-error class from it alone, so a list without it fails to type-check, and in plain JavaScript an extra key's wrapper loses its error class. SchemaField withholds rawErrors from the FieldTemplate while the errors are hidden, as it always has, and now does so for a layout grid's discriminated cell too. A template's rawErrors is deprecated, though: take the error state from its hasErrors prop, the field's own errors with ui:hideError applied. A custom field that renders the theme's FieldTemplate itself, rather than through SchemaField, passes hasErrors, a required prop, alongside the errors it hands over: the built-in templates take their error state only from it, so a field passing rawErrors with hasErrors={false} keeps its messages but has no red outline or invalid status. In @rjsf/antd it also decides whether the messages are shown; a field passing just rawErrors still has them shown through v7, without the form item's error status. v7 requires hasErrors rather than falling back to rawErrors the way 6.x templates did: TypeScript reports a template rendered without it, and from plain JavaScript, where nothing checks the type, such a template has no error state in any theme, and neither does the WrapIfAdditionalTemplate it hands its props to. ArrayFieldTemplate and ObjectFieldTemplate are handed their own errors as rawErrors whatever hideError says, as a widget is; that rawErrors is deprecated too. They take their error state from their own hasErrors, as a FieldTemplate does, and the messages they draw from getErrorsToDraw(), which gives presentation.errors.messages only where presentation.errors.by is 'self', since the FieldTemplate draws them under 'template'. hasWidgetErrors() follows the aria-invalid its field's plan gives its control, so an anyOf/oneOf option's control is styled invalid while the field above draws the errors. That cell's template is also handed the classNames and style it never was, so its wrapper now carries rjsf-field-error while the field has errors to show; see A discriminated cell of a layout grid renders through SchemaField. What a FieldTemplate gains is errorSchema, which ArrayFieldTemplateProps and ObjectFieldTemplateProps already declared: it carries every error whatever hideError says, so a template that renders the errors itself can reach the ones the directive withholds, which blanking rawErrors had left it no way to do. A template that spreads its leftover props onto a DOM node should destructure errorSchema out along with the rest, or React will warn about an unknown errorschema attribute.

The form-level error list is unaffected; it is controlled separately by showErrorList.

A ui:field component that renders SchemaField for its own data, and hands that field a ui:hideError of false while its own hides the errors, now has the field's own errors drawn by that inner field, which is styled in error and has its control marked aria-invalid. In 6.x the inner field drew nothing for them, since only the field naming the component had them, and while it hid them, nothing showed. A component that wants them hidden throughout leaves ui:hideError alone in the uiSchema it hands on, or sets it to true.

A discriminated cell of a layout grid renders through SchemaField BREAKING CHANGE​

A discriminated oneOf/anyOf that a LayoutGridField lays out renders its option selector alone, while the grid lays out the selected option's properties in cells of their own. LayoutMultiSchemaField rendered that cell outside any SchemaField, building its FieldTemplate's props itself, so each prop it left out was missing for that one field: no rjsf-field* rule and no ui:classNames ever reached it, for one (#5327). The grid now renders the cell through SchemaField, as it renders every other cell, and LayoutMultiSchemaField renders only the selector widget, in place of the options, so the cell's template is handed what every other field's is, resolved the same way (#5419).

What changes for a form that already renders one of these cells:

  • A CSS rule matching .rjsf-field, .rjsf-field-<type> or .rjsf-field-error now matches the cell's wrapper, where it never did. A rule written to style every field will start applying to these cells; a rule written to exclude them has to say so.
  • ui:classNames and ui:style on the cell are applied by the FieldTemplate wrapper, and are kept out of both the options and the uiSchema handed to the selector widget, the way SchemaField has always kept them from its children (see #439). A custom selector widget that styled itself from options.classNames/options.style, or from getUiOptions(props.uiSchema), no longer receives them. Read them from ui:options under a name of your own if the widget, rather than the wrapper, is what should carry them.
  • The template is handed the hidden, formData, description, rawDescription, help and rawHelp props it never was. A ui:widget: 'hidden' on such a cell now hides it, where the widget used to render inside the full wrapper, and a ui:description, schema.description or ui:help on it now renders.
  • ui:disabled and a deprecated schema are resolved as for any other field, where the cell ignored both. ui:disabled now disables the cell and its selector widget, and re-enables them when it is false on a form or a grid that disables everything. A deprecated cell is hidden by ui:deprecatedHandling: 'hide', disabled by 'disable', and by the 'label' default has the deprecation marker appended to its label, which its FieldTemplate and its selector widget both receive.
  • A cell with no title is labelled by the name the grid gives it, as every other cell is, where it was left unlabelled: its property name (see A layout grid cell is named by its property name). Give it a ui:title, or set ui:label: false to hide the label; the selector is still named by its text.
  • A field of your own registered as LayoutMultiSchemaField is rendered within the FieldTemplate that SchemaField renders, so one that rendered its own FieldTemplate now renders two. Render only the selector, and take disabled, readonly, required, hideError and presentation from its props, which SchemaField has resolved from the cell's uiSchema. The grid names the field in the cell's ui:field, in place of any field the cell or its ui:definitions entry names, which a discriminated cell has never rendered and the console now warns once about, and sets ui:fieldReplacesAnyOrOneOf beside it, so the options give way to the selector as they do to any field that renders them. The field keeps both out of the uiSchema it hands its selector widget, so a custom selector widget reading getUiOptions(props.uiSchema) doesn't see them. The cell's FieldTemplate, DescriptionFieldTemplate, FieldHelpTemplate and FieldErrorTemplate still do, in the uiSchema SchemaField hands them. A template that decides whether to draw the label with the deprecated getDisplayLabel() now draws none for a cell with no type, since that turns the label off for any uiSchema with a ui:field, and a RadioWidget selector's aria-labelledby then names nothing; read presentation.label.by, or templateDrawsLabel(), instead.
  • A $ref cell takes its ui:definitions entry, which it ignored, so a definition shared between a grid cell and a field elsewhere now gives the cell its ui:widget, ui:title and the rest too.
  • ui:autofocus on the cell focuses its selector, where the widget was handed autofocus: false and nothing took focus.
  • LayoutMultiSchemaField reads its options from the schema's oneOf/anyOf, and no longer reads an options prop, which only the grid passed, or the undeclared baseType. The grid no longer passes options to any cell, so a custom ui:field in a oneOf/anyOf cell without a discriminator, which received it through SchemaField, has to read the options from schema.oneOf/schema.anyOf instead. Named outside a grid it no longer throws, but it is still meant for one: without ui:fieldReplacesAnyOrOneOf: true the options render beside it with a selector of their own, and with it nothing renders the selected option's properties.
  • A selector property with no type, nor one its const or enum implies, takes the type of the first option that isn't a boolean schema, declared or inferred, where it took the selected option's, so its widget no longer depends on the data, and a selector with no option selected renders, where it threw. With no option to take a type from, as when every option is a boolean schema, it is typed as an object, with its default widget or one the cell names by an alias an object takes: select, radio or hidden.
  • computeEnumOptions() and getSelectedOption() can no longer be imported, since deep imports are removed.
  • A cell whose oneOf/anyOf is empty has no option to select, so it renders the field its schema would without the keyword, as a cell with no union does, where it threw.

The rjsf-field-<type> class is rjsf-field-undefined for the usual discriminated schema — { discriminator, oneOf: [{ $ref }, ...] } declares no top-level type, and getSchemaType() has nothing to infer one from. That is what SchemaField has spelled for any typeless schema since 6.x, which is the only thing this change promises about it. It is not a spelling to key a rule off: a later release may drop the type class for a schema it cannot type, or derive it from the selected option, in getFieldClassNames() for every field at once. Key off .rjsf-field, which every field carries whatever its schema says.

A custom field that renders its own FieldTemplate should build these props with the three new @rjsf/utils helpers rather than by hand, so it keeps carrying whatever the contract gains next:

import { ERRORS_KEY, getDeprecatedHandling, getFieldClassNames, getUiOptions, omitConsumedStyling } from '@rjsf/utils';

const uiOptions = getUiOptions(uiSchema, registry.globalUiOptions);
// A field resolves `ui:hideError` itself, in either direction, falling back to the prop, which carries the parent's
// value rather than this field's; and its errors arrive in `errorSchema`, not in the `rawErrors` a widget is handed,
// so `hasWidgetErrors(props)` is not the answer here the way it is inside a widget. A field that plans itself with
// `resolveFieldPresentation()` reads this from its plan's `errors.messages` instead
const hideError = uiOptions.hideError === undefined ? props.hideError : Boolean(uiOptions.hideError);
const hasErrors = !hideError && (errorSchema?.[ERRORS_KEY]?.length ?? 0) > 0;
const classNames = getFieldClassNames(schema, hasErrors, uiOptions.classNames);
// `hide` means the `hidden` prop, `disable` means the `disabled` one, and `label` decorates the label handed to the
// template — but not the one handed to the widget below it
const deprecatedHandling = getDeprecatedHandling(schema, uiOptions);
// What to hand the children, with the styling this template consumed removed
const childUiSchema = omitConsumedStyling(uiSchema);

Pass rawErrors on the same terms SchemaField does — the full list while the errors are shown, undefined while they are hidden — and pass errorSchema through unchanged, so a template rendering the errors itself can still reach the hidden ones.

A LayoutGridField with no layout, or a cell or condition naming no field, renders nothing BREAKING CHANGE​

A LayoutGridField with no ui:layoutGrid object, because it is missing, misspelled or not an object, rendered every field of its schema, as a plain object field does. It now renders nothing, as the ui:layoutGrid reference says, and logs a warning naming the field. A cell that names no field and whose render doesn't resolve, such as {}, { name: '' } or { render: 'Misspelled' }, rendered every field of the grid's schema again inside the grid, under the same ids, and now renders nothing too, logging a warning unless the cell is null. In a ui:columns, which renders a GridTemplate around each cell, its column is left empty.

A ui:condition with no field, or a field of '', read no value, so a none condition such as { "ui:condition": { "operator": "none", "value": "x", "children": ["a"] } } rendered its children. It is now never met, so it renders nothing and logs a warning.

To keep those fields, give the LayoutGridField its ui:layoutGrid, or drop its ui:field so it renders as a plain object field. Remove a cell that names no field, give it the name of the field it should render, or fix its render. Give a ui:condition the field it tests, or replace a condition that should always render with its children.

A layout grid cell is named by its property name, not its dotted path BREAKING CHANGE​

A LayoutGridField cell that names a nested field, such as person.address.city, handed that full dotted path to the cell's SchemaField as its name, so a cell with no title was labelled person.address.city. It now hands over the property name, city, so the cell is labelled by it, as the same field is outside a grid. A cell that names an array item, such as person.list.0, is named list-0, as ArrayField names that item. Ids, htmlName, form data and errors come from the full path, so they don't change.

A grid shows its cells side by side without the headings of the objects that hold them, so two untitled cells with the same property name, such as person.name and company.name, are now both labelled name. Give each one a ui:title to tell them apart.

The name reaches more than the label: it is the name prop of the cell's field, including one the cell names by ui:field, and of its widget, and the keyName of its WrapIfAdditionalTemplate. A custom field, widget or template inside a grid that read the dotted path from name now gets the property name. A field or FieldTemplate reads the full path from fieldPath instead, which is unchanged. A widget or WrapIfAdditionalTemplate is handed no fieldPath; one that needs the path takes it from the custom field or FieldTemplate that renders it, which reads fieldPath. A cell with a render component still hands it the dotted path as name.

A nested cell with no title that you want to keep the old label gives it a ui:title:

{
"ui:field": "LayoutGridField",
"ui:layoutGrid": { "ui:row": [{ "ui:col": ["person.address.city"] }] },
+ "person": { "address": { "city": { "ui:title": "person.address.city" } } }
}

EnumOptionsType.value is unknown BREAKING CHANGE​

An enum option's value is typed unknown instead of any, since it can be any JSON value an enum, const or examples entry holds. The helpers typed by it follow: enumOptionsSelectValue(), enumOptionsDeselectValue() and enumOptionsValueForIndex() return unknown values, and enumOptionSelectedValue() takes value as unknown and returns a string (a string[] when multiple) or its emptyValue (undefined when it is omitted) instead of any. Its second type parameter is the type of emptyValue and defaults to undefined, so a call that passes <S> explicitly along with an emptyValue, as the v6 themes did, no longer compiles: drop the type arguments and let them be inferred, or pass <S, typeof emptyValue>. UIOptionsType.emptyValue is unknown as well, as is every ui:options value read through the type's index signature, so an emptyValue taken from options and returned by enumOptionSelectedValue() is unknown until it is narrowed. Nothing changes at runtime; only code that used a value as a narrower type stops compiling. Compare values with === (or deepEquals() from @rjsf/utils for object and array values) rather than includes(), which no longer accepts an unknown argument (for ui:enumDisabled, use enumOptionsIsDisabled() from @rjsf/utils, which also matches object and array values by deep equality), and render an option's label rather than its value. An unknown value no longer fits a value attribute either, and String() would turn every object or array value into "[object Object]" or a comma-joined list. Encode the options with enumOptionsDomValues() instead, reading each option's DOM value at its index, and decode the attribute in the change handler with enumOptionValueDecoder(), which is what the built-in select widgets do; the two must use the same format, which getOptionValueFormat(options) reads from ui:optionValueFormat:

// Before
<select value={value} onChange={(event) => onChange(event.target.value)}>
{enumOptions.map((option) => (
<option key={option.value} value={option.value} disabled={enumDisabled.includes(option.value)}>
{option.value}
</option>
))}
</select>

// After
import {
enumOptionSelectedValue,
enumOptionsDomValues,
enumOptionsIsDisabled,
enumOptionValueDecoder,
getOptionValueFormat,
} from '@rjsf/utils';

const { enumOptions = [], enumDisabled, emptyValue } = options;
const optionValueFormat = getOptionValueFormat(options);
const domValues = enumOptionsDomValues(enumOptions, optionValueFormat);
<select
value={enumOptionSelectedValue(value, enumOptions, false, optionValueFormat, '')}
onChange={(event) => onChange(enumOptionValueDecoder(event.target.value, enumOptions, optionValueFormat, emptyValue))}
>
{enumOptions.map((option, index) => (
<option
key={domValues[index]}
value={domValues[index]}
disabled={enumOptionsIsDisabled(option.value, enumDisabled)}
>
{option.label}
</option>
))}
</select>

AnyOfField and OneOfField take their options already retrieved BREAKING CHANGE​

SchemaField retrieves each anyOf/oneOf option for the field's data and hands the retrieved schemas to AnyOfField/OneOfField as options, which used to retrieve them a second time. A custom field that rendered one of them itself with the raw option schemas, say <AnyOfField {...props} options={schema.anyOf} />, now retrieves them first. An option may be a boolean schema, which SchemaField retrieves as an empty one:

const options = (schema.anyOf ?? []).map((option) =>
registry.schemaUtils.retrieveSchema(typeof option === 'object' ? option : {}, formData),
);
return <AnyOfField {...props} options={options} />;

A custom field that passed the options prop it received from SchemaField on is unaffected.

FieldTemplateProps.rawHelp carries an element ui:help BREAKING CHANGE​

rawHelp is string | ReactElement and carries whatever ui:help resolved to, where it used to be string and silently undefined for a ui:help given as a React element. It is now a reliable answer to whether a field has any help at all, which is what a FieldTemplate gating on it wants:

// Before: renders nothing for `'ui:help': <strong>…</strong>`, since `rawHelp` was `undefined`
{rawHelp && help}

// After: renders the element too, with no change to the code
{rawHelp && help}

A string ui:help is unaffected: rawHelp already carried it. Only the element form was lost. @rjsf/antd's FieldTemplate gated on rawHelp this way and so dropped an element ui:help along with the field's errors; every theme and every custom FieldTemplate doing the same is fixed by this change.

Two things to check in code that reads rawHelp as a string:

  • A template using it where a string is required — rawHelp.length, a template literal, or a string-typed prop — no longer compiles. Narrow it first (typeof rawHelp === 'string' ? rawHelp : undefined) to recover exactly the old value.
  • A template that spreads its leftover props onto a DOM node should destructure rawHelp out along with the rest. React warns about the unrecognized prop either way, but an element value renders as rawhelp="[object Object]" where a string used to render as itself.

Clearing a custom error now also clears it from the error list BREAKING CHANGE​

A field, widget or template can raise its own error by passing an errorSchema as the third argument of onChange:

onChange(value, fieldPath, { __errors: ['must be a company address'] });

Clearing it again — calling onChange for the same path with no errorSchema — used to remove the message from the field but leave it in the list the ErrorListTemplate renders, until the next validation replaced the list wholesale. Both now drop the entry at the same time.

This was an aliasing accident rather than a design: when the validator's own error schema is empty, validationDataMerge() returns the additional schema by reference, so the form's committed errorSchema and the custom-error builder were the same object. Clearing mutated it in place, which the field saw when it re-rendered, while the top-level errors array was never rebuilt. The form no longer mutates committed state, so the list is recomputed instead.

If you have a test asserting that a cleared custom error is still listed at the top of the form, it will now fail; the error is gone from both places.

An error a field raises is kept apart from the validator's BREAKING CHANGE​

An error raised through onChange at a path the validator had already reported an error for used to be written into the validator's result, in place of that error. The form now keeps what a field raises to itself, whether or not the validator reported anything there. The raise still takes the validator's errors at its path off the display, so onChange(value, fieldPath, { __errors: [] }) clears them as before.

Three things follow:

  • A parent that passes formData and stores what onChange reports no longer loses the raise when it renders the edit back.
  • The raise is still shown after the next validation, beside whatever the validator reports for the new value, the way a raise at a path without a validator error always was. It used to be replaced by that validation. Like any error a field raises, it keeps the form from submitting for as long as it stands. A field clears its own error by raising again, or by calling onChange with no errorSchema. The latter clears the error at the field's own path only: errors the field raised for paths below its own stay until it raises again, and raising {} takes all of them off.
  • In errors, and so in the ErrorListTemplate, the raise is listed after the validator's errors and extraErrors, where it used to take the place of the validator error it replaced.

Raising an empty errorSchema now clears the field's earlier raise at that path too, where it used to clear only the validator's error and leave the field's own standing.

The errorSchema passed to onChange holds only the field's own errors BREAKING CHANGE​

The errorSchema a field, widget or template passes to onChange is now read as the errors that field itself raises at its path, and nothing else. The form used to accept the errorSchema prop handed back, with the validator's errors and extraErrors in it, and worked out by comparing messages which entries were the field's own. An error of the field's own that read the same as an extraErrors message at the same path was therefore taken for the parent's, and disappeared when the parent cleared extraErrors.

A widget or field that raises the messages it computes needs no change:

onChange(value, value === 'test' ? undefined : { __errors: ['Value must be "test"'] }, id);

A field that passes its errorSchema prop back, or a copy of it with entries added, now claims every error in it as its own. They stay on display after the validator stops reporting them and after the parent clears extraErrors, until the field raises again. Pass only what the field raises:

// Before
onChange(newValue, fieldPath, errorSchema);
onChange(newValue, fieldPath, { ...errorSchema, __errors: [...(errorSchema?.__errors ?? []), 'Bad value'] });

// After
onChange(newValue, fieldPath);
onChange(newValue, fieldPath, { __errors: ['Bad value'] });

A raise still takes the validator's errors at and below its path off the display until the form validates again, so raise at the narrowest path the error belongs to: a raise at an object's path removes the validator's errors of the properties below it. It also replaces the errors the fields below it raised themselves, since a raise is everything the field has to say about its path. A raise at the root therefore replaces those of every field in the form.

ArrayField no longer passes an errorSchema when it adds, copies, removes or reorders items; the form moves the errors it holds for the items itself. Three things to check:

  • A custom field or template that wraps ArrayField and passes its onChange on in the same call is unaffected. One that passes it on later, from a debounce or after an await, sends a change the form no longer knows the move of, so the errors do not move with the items, as for a field written from scratch below. One that listened for the remapped errorSchema in the third argument of that onChange no longer receives it. One that adds an error of its own to the changes it passes on raises at the array's path, and so replaces the errors the items' fields raised themselves: there is no remapped errorSchema left to merge it into.
  • A custom field that hands ArrayField a copy of the array it sorted or filtered itself is not supported: ArrayField adds, copies, removes and reorders by reading the form's value at its fieldPath, so those operations, and the errors they move, act on the form's indexes and not on the copy's. Pass formData through unchanged, see Form data has one owner.
  • A custom array field written from scratch has no way to tell the form where an item went, so the errors of its items do not move with them. Without live validation, the validator's errors of the items whose values changed are cleared. Validating again, with liveValidate or validateForm(), puts the validator's errors in place, but not the errors the items' fields raised themselves: those stay at the old indexes, and one left at the index of an item that was removed keeps the form from submitting. Raise at the array's path with the change, onChange(newItems, fieldPath, {}) to clear them or an errorSchema holding them where their items went.

The optional data controls no longer pass the field's errorSchema back either. Adding data passes none. Removing it passes an empty one, which clears the errors at and below the removed value, the validator's and the fields' own: they describe fields that are no longer shown, and an own error left standing would keep the form from submitting. Removing an additional property clears the errors at and below its key the same way.

Two more changes take the errors of the fields they affect along, without an errorSchema. Renaming an additional property moves the errors under its key to the new name, where they used to stay under the old one. Switching the option of a oneOf or anyOf drops the errors the fields raised under every key the option that was left declared or the data held, and at the field's own path: the option's fields are rendered anew under the new option's schema, so a raise of the old option's widget would stand on a field that never made it. The validator's errors go only under the keys the new option does not declare, whose fields are gone; under a key it declares too they may describe a constraint it shares, and stay until the form validates again, as does one on the switched field itself. A property the schema declares beside its options is still the same field and keeps its errors.

A custom ArraySchemaField is handed each item's own errors as rawErrors BREAKING CHANGE​

ArrayField hands the field component it renders for each item, a custom ArraySchemaField or SchemaField, the item's own errors as the deprecated rawErrors. It used to hand every item the array's errors, so the docs told a custom ArraySchemaField to read its own from its errorSchema. Nothing warns about the change, so check a custom ArraySchemaField that reads rawErrors:

  • One that drew rawErrors next to each item as the array's errors now draws that item's own there. Draw the array's errors in the ArrayFieldTemplate, which is handed them, instead.
  • One that drew rawErrors and also hands its errorSchema to a SchemaField, which draws the __errors it holds, now draws the item's errors twice. Draw only one of them:
// Before: rawErrors held the array's errors
const CustomArraySchemaField = (props: FieldProps) => {
const { rawErrors, registry } = props;
const { SchemaField } = registry.fields;
return (
<>
<SchemaField {...props} />
{rawErrors?.map((error) => <p key={error}>{error}</p>)}
</>
);
};

// After: rawErrors holds the item's own errors, which the SchemaField already draws
const CustomArraySchemaField = (props: FieldProps) => {
const { SchemaField } = props.registry.fields;
return <SchemaField {...props} />;
};

noValidate now turns off live validation on blur BREAKING CHANGE​

noValidate disables validation everywhere else — on change, on submit, and when props change — but liveValidate='onBlur' ran and displayed schema errors regardless of it. It is now honored there too.

If you combined noValidate with liveValidate='onBlur' and depended on blurring a field to produce errors, drop noValidate:

-<Form noValidate liveValidate='onBlur' ... />
+<Form liveValidate='onBlur' ... />

FormRef.validate() takes only the data BREAKING CHANGE​

validate() now takes only the data to validate: validate(formData). Its schema, altSchemaUtils and retrievedSchema parameters are gone. They existed for the form's own validation passes, which call the internal pipeline directly. Nothing on the handle is typed by the schema or the form context any more, so the type is FormRef<T> rather than FormRef<T, S, F>.

To validate against another schema or validator, build the utilities for it and call its validator yourself, passing the form's customValidate, transformErrors and uiSchema as validate() did:

-const { errors } = formRef.current.validate(data, otherSchema, otherSchemaUtils);
+const { errors } = otherSchemaUtils
+ .getValidator()
+ .validateFormData(data, otherSchema, customValidate, transformErrors, uiSchema);

To hand customValidate the same defaults the form would, pass the trailing getCustomValidateFormData: () => otherSchemaUtils.getDefaultFormState(otherSchema, data, true, undefined, uiSchema).

validate() also added the errors of ui:required fields on top. For those, merge otherSchemaUtils.getUiRequiredErrorSchema(uiSchema, data) into the result with validationDataMerge().

@rjsf/daisyui applies ui:classNames and ui:style on the field wrapper BREAKING CHANGE​

@rjsf/daisyui's FieldTemplate applied ui:classNames twice — once on the WrapIfAdditionalTemplate wrapper it renders every field in, and again on its own inner field-template div — while applying ui:style to that inner div alone (#5327). A ui:classNames declaring a border or padding drew it twice, one nested inside the other, and a ui:style written to go with those classes landed on a different element than they did. Both now go to the wrapper, the way @rjsf/core and every other theme applies them.

ui:options.daisy's own className, style and theme are unaffected. They are this theme's per-field theming of that inner div, which is what they are for.

Two things to check:

  • A CSS rule or a test that reached ui:classNames on .field-template, or read ui:style off it, has to look at the wrapper above it instead.
  • A custom WrapIfAdditionalTemplate registered for this theme is now what applies both, so one that renders <div className={classNames}> and ignores style silently drops every ui:style on upgrading, where ignoring it was harmless before. Apply style alongside classNames on the element you return, in the plain branch as well as the additional-property one.

@rjsf/mantine widgets reference rjsf's ids for labels and descriptions BREAKING CHANGE​

Most @rjsf/mantine widgets passed aria-describedby to a Mantine component that discarded it, and the checkbox and radio groups pointed aria-labelledby at a Mantine label that was never rendered (#5332). Every widget now references the ids the other themes use: an input is described by the field's error, description and help ids (root__error root__description root__help), and a checkbox or radio group is labelled by the field's title id (root__title). Accessibility is all that changes, apart from a few places where the markup your code or tests may select on is different:

  • RadioWidget's label has the field's title id (root__title) instead of the id Mantine generated for it (root-label). CheckboxesWidget's label keeps that id, but Checkbox.Group now renders it, as a <div class="mantine-CheckboxGroup-label"> inside the group's wrapper, where it used to be a <label> before the group. Both groups are labelled by it, where before the checkbox group had no accessible name. When the label is hidden (ui:label: false) or left out of inputWrapperOrder, it is still rendered with that id, hidden, so the group keeps its name; when the field has no label, a RadioWidget's group is named by a hidden title holding its fallback name, and a CheckboxesWidget's has no aria-labelledby. An aria-label in ui:options names either group the same way, over a hidden label, and no longer lands on Mantine's wrapper; a label on screen still names the group. Select the label by #root__title, or better, the group by its accessible name:

    -container.querySelector('#root-label');
    +screen.getByRole('radiogroup', { name: 'My field' });
  • CheckboxesWidget and RadioWidget apply labelProps to their label, except an id, since the label's id has to match the group's aria-labelledby. As in Mantine's groups, labelProps comes from ui:options or the Mantine theme's CheckboxGroup/RadioGroup defaultProps, then ui:options.wrapperProps, then the theme's InputWrapper defaultProps, so a theme group default wins over ui:options.wrapperProps. CheckboxesWidget used to ignore labelProps entirely.

  • RangeWidget and AltDateWidget render their title, description and errors inside Mantine's Input.Wrapper (.mantine-InputWrapper-root), where they used to render them as siblings of the slider or the date parts. The InputWrapper options apply to them as to Mantine's inputs: an option in wrapperProps, from ui:options or else the Mantine theme's Input and InputWrapper defaultProps, wins over the same option set top-level, from ui:options or else the theme. So a theme's wrapperProps value beats a top-level ui:options one, and an option set to undefined in wrapperProps falls back to the theme's InputWrapper defaultProps. inputWrapperOrder orders or leaves out the title, description and errors, and labelProps, descriptionProps, errorProps, withAsterisk, labelElement and size style them; the slider and the date parts take that size too, unless the Mantine theme's Slider or Select defaultProps give them one and the field sets none of its own. A field that sets both size and wrapperProps.size sizes its inputs by size and its wrapper by wrapperProps.size, as a Mantine input does. The theme's Input styling props (classNames, styles, className, style, variant and the like) reach the wrapper too, as they do an input's. On AltDateWidget, the root element props from the theme's Input and InputWrapper defaultProps (className, style, mod, style props such as mt, and the root entry of classNames, styles and attributes), and the theme's Input wrapperProps other than the InputWrapper options and the Styles API props (classNames, styles, attributes, variant and the like, less their root entry), stay on each date part's own wrapper and aren't applied again around them all; set them in ui:options.wrapperProps to style the field's wrapper. The title keeps the field's title id (root__title) whatever labelProps says, has no for, and when it is hidden or left out of inputWrapperOrder it is rendered hidden, so the inputs keep their name. Their errors render in Mantine's single error element instead of one element per message. AltDateWidget applies an inputContainer around each date part rather than around them all, resolved as for a Select. Only the inputContainer of a ui:options.wrapperProps reaches the parts, over the theme's Select wrapperProps rather than replacing them, since the field's own wrapper applies the rest. Set these options on InputWrapper in the theme, not on Slider: Mantine's Slider spreads its own defaultProps on its root element.

  • RangeWidget renders its description by the same rules as the other widgets: it shows schema.description, which it used to leave out, and hides the description under ui:label: false, where it used to show ui:options.description regardless.

  • RangeWidget no longer passes Mantine input props that Slider doesn't take (leftSection, withErrorStyles, loading and the like) or the InputWrapper options from ui:options on to the slider's root element, where they became unknown attributes.

  • RangeWidget, PasswordWidget, TimeWidget, ColorWidget, FileWidget and DateTimeInput name their input by the nameGenerator, which they ignored. Without one, they, TextareaWidget and CheckboxWidget name their input by the field's id (root_address_city), as the other widgets do, where they used the bare property name (city, or "" at the root). Update any selector or form-data parsing that relied on the old name.

  • Every widget that renders a description, except CheckboxWidget, applies descriptionProps from ui:options, ui:options.wrapperProps or the Mantine theme's defaultProps, resolved as Mantine resolves them, where only ui:options.wrapperProps.descriptionProps used to reach it. The description is always rendered in a div, including under a wrapperProps.descriptionProps, which used to turn it back into Mantine's default <p>.

  • Mantine's error element has the field's error id (root__error) instead of the id Mantine generated for it (root-error), whatever errorProps.id says. FieldErrorTemplate shows, in Mantine's error element with that id, the errors no widget shows: those of a custom widget or ui:field, of @rjsf/core's RatingWidget, which has no Mantine counterpart, of a field of an unsupported type, of an array rendered item by item and of a oneOf/anyOf field, below its option. It used to render a hidden copy of every field's errors, so those were announced but never shown (#5490). ObjectFieldTemplate shows the object's own errors with that id, below its fields, where they used to be hidden; a custom ObjectFieldTemplate renders them only if it reads presentation.errors.messages itself. Inputs are now described by the error they show, including a oneOf/anyOf selector, which had no error to reference, and a checkbox's error is read once rather than twice. A oneOf/anyOf field's errors show once, below its option, and describe both the option selector and the option's control, each marked invalid, where the selector and the option's widget each showed them. CheckboxWidget renders its error below the checkbox, where Mantine's Checkbox rendered it below the label, and ignores errorProps, as before. A widget whose inputWrapperOrder leaves out 'error' shows its errors through FieldErrorTemplate instead, so a custom FieldErrorTemplate renders only errors Mantine's own element doesn't, and can give its element the field's errorId(id), as @rjsf/core's does. A widget of your own takes the declaration of the key it is registered under: under one of the theme's widget keys, such as TextWidget, it is declared to draw its own errors, so it should render presentation.errors.messages; under any other, or given directly as ui:widget, its errors are shown by FieldErrorTemplate. One registered as RangeWidget, AltDateWidget or AltDateTimeWidget is also declared labelledBy: 'aria-labelledby', so a label the template draws for it has no for, and it is named only through the aria-labelledby it spreads from getControlAttributes(). A BaseInputTemplate of your own draws the errors of @rjsf/core's TextWidget, EmailWidget, URLWidget and UpDownWidget, which the theme declares errors: 'self' because its own BaseInputTemplate draws them, so it should render presentation.errors.messages as well, or they aren't shown. Select an error by #root__error, or better, the input by its accessible description:

    -container.querySelector('#root-error');
    +expect(screen.getByRole('textbox')).toHaveAccessibleDescription(/must be string/);
  • Mantine's success message, rendered for a success option, has the id from successProps.id, else root__success, even when wrapperProps has an id, and inputs are described by it when it is shown. RangeWidget and AltDateWidget render it once, on the field's wrapper, and describe the slider thumb or each date part by it; a date part no longer renders one of its own, even from the theme's Select defaultProps. CheckboxesWidget and RadioWidget describe each option by it, rather than the group, like the field's other ids.

  • Each AltDateWidget date part is labelled by the field's label followed by the part, so a query by the part alone no longer matches when the field has a label, which falls back to its property name when there is no title:

    -screen.getByRole('combobox', { name: 'year' });
    +screen.getByRole('combobox', { name: 'My field year' });

Snapshot tests of Mantine forms will change too, as inputs gain the rjsf ids of the parts that render in aria-describedby and the hidden copy of the errors is gone. An assertion on an input's accessible description (toHaveAccessibleDescription()) now includes the field's help text and errors along with its description. RangeWidget's aria-describedby moves from the slider's root element to its focusable role="slider" thumb, and so does the field's id (root_x), so focusOnFirstError reaches the thumb; select the slider's root by .mantine-Slider-root. The field's id replaces an id given in thumbProps.

getDisplayLabel() reads a boolean's widget from the reduced ui options BREAKING CHANGE​

A boolean field left to its default widget is a checkbox, which draws its own label, so getDisplayLabel() reports false for it and the theme's FieldTemplate renders none. It decided that from the ui:widget key alone, and ui:options: { widget: 'radio' } is the same choice spelled the other way, so a boolean naming its widget that way was treated as having no widget at all: the template suppressed the label, leaving a radio group or a select with nothing above it (#5318).

FieldTemplateProps.displayLabel is now true for that spelling, in every theme. A FieldTemplate of your own needs no change; a snapshot of a form with a boolean field whose widget is set through ui:options.widget gains that field's label.

Naming the checkbox a boolean already resolves to, by the CheckboxWidget key it is registered under, now counts as naming no other widget and reports false. A checkbox renders the field's label itself after its input, so ui:widget: 'CheckboxWidget' had every theme render a second label beside the widget's own; that field now carries one label again. The checkbox alias still reports true, since that is the spelling a FieldTemplate matches to lay a checkbox field out itself, taking over the field's description along with its label. getDisplayLabel() itself still recognizes a widget only by name, so one registered under some other key, or passed to ui:widget as a component, reports true. SchemaField no longer asks it whether a field's own widget draws its label (see below): it looks the widget up by its alias, the key it is registered under or the component itself, and draws the label where that widget's declaration, from the theme or the Form's presentation prop, says, so a self-labelling widget registered under another key and declared label: 'self' draws one label however ui:widget names it (#5389). A component passed to ui:widget that is registered under no key has no declaration of its own and is planned by the theme's default. Where that default leaves the label to the template, as in @rjsf/core, @rjsf/antd, @rjsf/daisyui, @rjsf/react-bootstrap and @rjsf/shadcn, one that draws its own label anyway, ignoring both its presentation and hideLabel, renders a label twice. Where it has the widget draw the label, as in @rjsf/chakra-ui, @rjsf/fluentui-rc, @rjsf/mantine and @rjsf/mui, one that draws no label renders none. Register it under a key and declare it in presentation to give it one label.

@rjsf/daisyui's date and label markup changed BREAKING CHANGE​

@rjsf/daisyui rendered its field label from the schema type rather than from the widget, and rendered it whether or not the field had a title, so several kinds of field were labelled wrongly or not at all; its DateWidget mangled a format: 'date' value at both ends (#5318). The theme's API only gained exports, so the migration is limited to snapshot tests of a daisyui form, stylesheets targeting these elements, and anything that stored what its date widget committed.

What a format: 'date' field commits is now YYYY-MM-DD, the shape @rjsf/core's native date input and every other theme produce, where it was a full UTC date-time such as 2020-05-03T00:00:00.000Z — not a date, so ajv-formats rejected the value the user had just picked. Closing the picker with no day chosen commits ui:emptyValue (undefined by default) rather than '', which failed that format the same way. A stored value carrying a time names the day its own text spells, whatever separator, time and zone follow it, so a 2020-05-03T00:00:00.000Z or a 2020-05-03T12:00:00+02:00 reads as 3 May. The exception is what v6 stored, toISOString() of the local midnight beginning the day the user picked: a value that spells that instant in UTC, however a backend writes it back (with or without the seconds and zero fraction, a space for the T, and Z, +00:00, +0000 or +00 for the zone), and lands on local midnight names that local day. A value with an offset of its own, or a lowercase t, came from somewhere else and is read as its text reads.

DateWidget reads an iso-date-time field as the day its text names, as DateTimeWidget does (#5395). v6's exception doesn't apply to that format, so a value v6 stored in an iso-date-time field under ui:widget: 'date' reads as the day before the one picked, for anyone ahead of UTC, until the day is picked again. DateTimeWidget reads a field by its format the same way, so in a format: 'date' field it shows the day a stored date-time names, at the start of that day, rather than the instant, which behind UTC is the evening before. Anything asserting on the committed shape sees 2020-05-03 where it saw a date-time. A value the widget cannot read at all is now left alone when the picker is dismissed, where it used to be replaced by the empty value.

Neither picker stores anything unless the user chose a date. Opening a picker and dismissing it used to commit whatever it was holding, so a format: 'date' field holding a date-time had the day the widget read out of it written back over the value — data the user never touched, rewritten by a glance at the calendar. A format: 'date-time' or iso-date-time field is left alone the same way, including the seconds neither picker can set. Picking a day is still the request to store one, and still rewrites a date-time in a date field as the date that field's format requires.

Either picker commits the shape the field's format declares, rather than the one its own name suggests: a date for format: 'date', a UTC ISO string for date-time, and a naive local date-time for iso-date-time. widgetMap resolves ui:widget: 'date' and ui:widget: 'date-time' on any string schema, whatever format it declares, so { 'ui:widget': 'date' } on a format: 'date-time' field used to commit a value that field's own format rejects, and { 'ui:widget': 'date-time' } on a format: 'date' field likewise.

What the markup looks like:

  • A field with no title renders no <label> at all, where an empty one used to take up space — every root field and array item, along with the lone required * on an untitled array item.
  • A boolean rendered through anything other than a checkbox or toggle now has a label, where it had none. Those two widgets render their own after the input, and so does the description. Which widget renders the label is decided from the registry, so a widget you register as CheckboxWidget or toggle — a wrapper around the theme's own, or a replacement — is the one that renders it. A widget registered under any other key gets the template's label as well as its own.
  • A widget rendering a group of controls marks it with role='radiogroup'/role='group', carries the field's id, and names itself from the label through aria-labelledby (a __label id suffix, since TitleFieldTemplate claims titleId()).
  • A hidden field renders only its children, as in @rjsf/core. An additionalProperties entry hidden with ui:widget: 'hidden' therefore loses its key input and remove button, and a field hidden by deprecatedHandling: 'hide' — the other thing SchemaField reports through that prop — is hidden rather than rendered in full.
  • ToggleWidget renders the field's label beside the switch, where it rendered ui:options.label — a flag, not text, so that label was always empty. It shares one input component with CheckboxWidget, which wraps the input in a <span> where both used a <div> a <label> does not permit, and makes the checkbox honor ui:autofocus. Both take the input's required attribute from schemaRequiresTrueValue(), as the * beside it already did: HTML5 constraint validation refuses an unchecked required box, so a boolean a parent merely lists as required — whose false the schema accepts, and which RJSF fills in as false — could not be submitted at all: the browser stopped the submit with its own prompt, before RJSF validated it or called onSubmit or onError.
  • BaseInputTemplate no longer renders a second, display: none label for its input, and the date widgets' triggers show ui:placeholder rather than schema.title.
  • Both date widgets open their picker from a <button type='button'> where they used a <div role='button' tabindex='0'>, so the field's label reaches the control it names. A stylesheet or snapshot targeting that div, or its role/tabindex, needs updating. That label names the button through its own htmlFor, whichever FieldTemplate rendered it. The button has the combobox role, as SelectWidget's trigger does, so it holds the date it displays as its value and is aria-invalid while its errors show; a query for either trigger by the button role should use combobox.
  • Both date widgets honor ui:disabled and ui:readonly, which neither read before: the picker opened from a disabled field and committed a new value over it. A second press on the trigger, or on the label pointing at it, now closes the picker and commits, where it closed and reopened in one gesture. Escape discards the date it was closed on, where the calendar kept it and the next close committed it.
  • CheckboxesWidget no longer puts required on every option box, which HTML5 constraint validation read as "check all of them" and which blocked the submit of a required array outright. RadioWidget keeps the attribute, where the browser does read same-named inputs as one group.
  • CheckboxWidget, ToggleWidget and both date widgets' triggers set aria-describedby, so the description, errors and help around them are announced with the control.
  • CheckboxWidget and ToggleWidget render the * for a required field whose schema is not a boolean, which ui:widget: 'toggle' can be pointed at: the template's label used to carry that marker, and the widget draws one of its own only for a boolean that has to be true.
  • Both date widgets' triggers show the translated AriaDateLabel string, "Select a date", for a field with no value, no ui:placeholder and no title, where the button rendered empty — blank on the screen and nameless to a screen reader. Escape also restores the month the stored value names, where the calendar reopened on the month the user had navigated away to and discarded.
  • Both date pickers' Done button and the DateTimeWidget's time input label go through translateString, as TranslatableString.DoneLabel ("Done") and TranslatableString.TimeLabel ("Time"), where they were hard-coded English; a translateString that maps every key needs the two new ones (#5482)
  • Both date widgets' popup is one element with the date-picker-popup class, and that element is the dialog the trigger controls: DateWidget's was a role='presentation' wrapper, and DateTimeWidget's had no class. Where the browser has native popovers it is a manual popover in the top layer, fixed below or above its trigger; elsewhere it is position: absolute just below the trigger at z-index: 100, as DateTimeWidget's was. For DateWidget that is a regression in Safari before 17 and Chrome before 114: an ancestor with overflow: hidden that doesn't scroll, such as a card or a collapse, now cuts off the calendar and its Done button, where the fixed popup at z-index: 99999 escaped it. Give that ancestor overflow: visible, or override .date-picker-popup's position and z-index with !important. Its position, size, margin, padding, overflow, overscroll behavior and text color, and that z-index, are inline styles, so a stylesheet changing any of them on .date-picker-popup needs !important, and a selector or query for its presentation role, or for a dialog inside it, should target .date-picker-popup or the dialog role itself (#5633)
  • getGroupProps() is exported from @rjsf/daisyui, which also re-exports @rjsf/utils' new fieldLabelId(). A group widget of your own that renders its controls itself should use getGroupAndOptionProps(), also exported, whose option gives each control the aria-describedby and aria-invalid its field's plan describes it by. A group of checkboxes also hands it the registry, and renders the requiredCue it returns inside the group, so a required one is named as required. A custom group widget takes its role and aria-labelledby from getGroupProps(), handed the field's presentation. Under a plan the label's id is the plan's, which getLabelId() reads, and beneath a label drawn above with the same id it differs from fieldLabelId()'s, so a widget pointing at fieldLabelId() itself can be named by the wrong label. A widget or FieldTemplate of your own can name itself the way the built-in ones do rather than hard-coding the id suffixes; fieldLabelId() is the label's id for a field rendered without a plan.

@rjsf/daisyui's useTheme() reads the stored theme, and ThemeContext is removed BREAKING CHANGE​

useTheme() read ThemeContext, which each ThemeProvider filled with the theme stored in localStorage under daisyui-theme when it mounted, or cupcake where none was stored. Outside a provider it got the context's default: night, which nothing on the page shows, and a setTheme() that did nothing.

useTheme() now reads the stored theme itself, as an external store, wherever it is called, and its setTheme() stores one. Inside DaisyUIFrameProvider it names the theme that frame shows: the frame's chosen dataTheme where it has one, which a stored theme doesn't change, and the stored theme where it has none. Everywhere but inside a DaisyUIFrameProvider with a chosen dataTheme, every caller names the same theme, and follows one that another caller, DaisyUIFrameProvider or another tab stores. Where nothing is stored, or the storage can't be read, it names cupcake. The server has no storage, so it renders a frame's chosen dataTheme there, or cupcake where no dataTheme is chosen. A hydrating client starts from the same theme, and then shows the stored theme where no dataTheme is chosen. ThemeProvider holds no theme of its own and renders its children as they are; it is kept so that an app that wraps its forms in it needs no change. ThemeContext is gone. It was never exported from the package root, only from lib/theme/Theme.js and lib/theme/index.js.

What an app has to change:

  • An import of ThemeContext through @rjsf/daisyui/lib/theme/Theme.js or @rjsf/daisyui/lib/theme/index.js no longer resolves. Read the theme with useTheme():

    -import { ThemeContext } from '@rjsf/daisyui/lib/theme/Theme.js';
    -const { theme, setTheme } = useContext(ThemeContext);
    +import { useTheme } from '@rjsf/daisyui';
    +const { theme, setTheme } = useTheme();
  • A ThemeContext.Provider that gave part of a page a theme of its own no longer reaches useTheme(), which names the stored theme everywhere but inside a DaisyUIFrameProvider with a chosen dataTheme. Keep that theme in your own state, and give it to DaisyUI through data-theme on an element around that part:

    -<ThemeContext.Provider value={{ theme: 'dark', setTheme }}>
    +<div data-theme='dark'>
    <Form schema={schema} validator={validator} />
    -</ThemeContext.Provider>
    +</div>
  • Code that relied on useTheme() returning night outside a provider, such as a data-theme={useTheme().theme} with no ThemeProvider around it, now gets the stored theme, or cupcake, a light theme, where nothing is stored. To keep showing night there, write data-theme='night' rather than reading it from useTheme().

  • ThemeProvider no longer holds its own theme. Two providers on one page each kept the theme stored when they mounted, and changed it only through their own setTheme(). Now a setTheme() called under either one changes the theme every useTheme() names, inside both providers and outside them, and so does a theme that DaisyUIFrameProvider or another tab stores. The one exception is a useTheme() inside a DaisyUIFrameProvider with a chosen dataTheme, which keeps naming that theme. Give providers that should show different themes their own data-theme, as above. A ThemeProvider can be removed; leaving it in place changes nothing.

@rjsf/daisyui no longer ships src/styles.css or depends on tailwindcss BREAKING CHANGE​

@rjsf/daisyui published src/styles.css, which imported Tailwind's preflight and utilities layers and set --color-primary and --color-secondary on :root. No documentation mentioned it and the package's exports never let it be imported by name, so it is removed, along with the tailwindcss dependency only it used.

An app that loaded the file through its path in node_modules loads Tailwind and daisyUI from its own stylesheet, as daisyUI's setup already asks. Don't carry the two variables over: they were Tailwind 3 style RGB triplets, which daisyUI 5 reads as invalid colors, so btn-primary and bg-primary lose their color. Set a daisyUI theme's colors instead.

-@import '../node_modules/@rjsf/daisyui/src/styles.css';
+@import 'tailwindcss';
+@plugin 'daisyui';

An app that relied on @rjsf/daisyui to install tailwindcss adds it to its own dependencies.

@rjsf/daisyui's SelectWidget names its open list of options by the field's label BREAKING CHANGE​

This affects tests that find a daisyUI SelectWidget by its field's label while its list is open, which it is whenever the select has focus, as after a click, ui:autofocus or focusOnFirstError.

The list had no name. While it is open, it is now named by the field's label, else its ui:placeholder, else TranslatableString.SelectPlaceholder ("Select ..."), so getByLabelText with the field's label finds both the combobox and the list, and throws. On a required field, whose label reads "Color*", it doesn't throw: the combobox is named "Color*", so getByLabelText('Color') matches only the open list, through the label's text, and returns it, and a test then clicks or types into the list rather than the combobox.

Find the combobox by its role and its whole name, "Color*" on a required field, which matches it alone, open or closed:

-screen.getByLabelText('Color');
+screen.getByRole('combobox', { name: 'Color' });

@rjsf/shadcn's SelectWidget names its combobox and fills empty placeholders BREAKING CHANGE​

The multiple SelectWidget rendered its cmdk search combobox with no accessible name, and its search input always showed a hardcoded, untranslated "Select ..." that ignored ui:placeholder; the single SelectWidget rendered a blank trigger where no ui:placeholder was given (#5458). The combobox is now named from the field's resolved label: the schema title or a ui:title override, falling back to ui:placeholder and then to the new TranslatableString.SelectPlaceholder ("Select ...") for an untitled field. The same fallback replaces the search input's hardcoded placeholder, and the field's description and aria-required reach the focusable combobox input. The single SelectWidget fills its empty trigger from the same fallback, so a single select without ui:placeholder now shows the translated placeholder where it used to render blank. Its trigger has the combobox role, so it holds the option it displays as its value and is aria-invalid and aria-required as its field is; with no label drawn for it, it is named by the field's label or, for an untitled field, by its ui:placeholder, else TranslatableString.SelectPlaceholder. A query for the trigger by the button role should use combobox.

The migration touches two kinds of code: visual snapshots of a shadcn form, where a single select without ui:placeholder goes from blank to "Select ..." and a multiple select with one shows it in place of "Select ..."; and a translateString table of your own, which gains the TranslatableString.SelectPlaceholder key to translate.

@rjsf/shadcn's multiple SelectWidget takes its field's id and opens only on user action BREAKING CHANGE​

This affects snapshots, selectors and tests that reach the search input of a shadcn multiple SelectWidget.

The input was cmdk's, with an id cmdk generated, such as radix-_r_3h_, and a cmdk-input attribute, and its list opened as soon as it took focus. The input now has the field's id, such as root_color, and no cmdk-input attribute, so the field's label and focusOnFirstError focus it. Its list opens on a click, typing, Down Arrow, Up Arrow or Enter, not on focus, so ui:autofocus and focusOnFirstError leave it closed.

Update snapshots of a form with a multiple select, find the input by the field's id or its role, and open the list before choosing an option:

-const input = container.querySelector('[cmdk-input]');
-input.focus();
+const input = screen.getByRole('combobox', { name: 'Color' });
+await user.click(input);
await user.click(screen.getByRole('option', { name: 'red' }));

@rjsf/shadcn's SelectWidget names its list of options by the field's label BREAKING CHANGE​

This affects tests that find an open shadcn SelectWidget's list of options by its name, or that find the select by its field's label while its list is open.

The list was always named "Suggestions", cmdk's default. It is now named by the field's label, else its ui:placeholder, else TranslatableString.SelectPlaceholder ("Select ..."), in the single and the multiple select. While the list is open, getByLabelText with the field's label finds both the combobox and the list, so it throws. On a required field, whose label reads "Color*", it doesn't throw: the combobox is named "Color*", so getByLabelText('Color') matches only the open list, through the label's text, and returns it, and a test then clicks or types into the list rather than the combobox.

Find the list by the field's label, or by its role alone, and the combobox by its role and its whole name, "Color*" on a required field, which matches it alone, open or closed:

-screen.getByRole('listbox', { name: 'Suggestions' });
+screen.getByRole('listbox', { name: 'Color' });
-screen.getByLabelText('Color');
+screen.getByRole('combobox', { name: 'Color' });

@rjsf/shadcn's RangeWidget ignores name, disabled, value and defaultValue from ui:options.props BREAKING CHANGE​

This affects a shadcn range that sets any of these in ui:options.props. The value replaced the form's value on the slider, so its thumbs stayed where it put them, and only the first thumb's value could reach the form. A name changed what the range submits under, and disabled: false let a user edit a range on a disabled or read-only form. RangeWidget now drops all four keys, so the form's data always sets the slider, and it draws one thumb. Other ui:options.props keys, such as min, orientation and onValueChange, still apply.

Set the starting value through the form's data, or the schema's default, instead:

const uiSchema = {
volume: {
'ui:widget': 'range',
- 'ui:options': { props: { value: [2] } },
},
};
+const formData = { volume: 2 };

For name, use the Form's nameGenerator. For disabled, use ui:disabled.

@rjsf/mantine ignores id, name, value, defaultValue, readOnly, autoFocus, data, multiple, error, onChange, onBlur and onFocus in ui:options BREAKING CHANGE​

This affects a Mantine field that sets any of these in ui:options. Options reach Mantine's components as props, so these could replace what the form passes: an onChange stopped the form seeing any edits, a name changed what the field submits under, a defaultValue showed an empty field with a value the form didn't have, a readOnly locked a field the form treated as editable, a data replaced a select's options so that picking one lost the value, and an error: false stopped marking an invalid field as invalid. They're now dropped as invalid options. Other options that Mantine's components take as props still apply.

  • For value and defaultValue, set the form's data or the schema's default.
  • For id, use the Form's idPrefix.
  • For name, use the Form's nameGenerator.
  • For readOnly, use ui:readonly, which renders the field disabled rather than read-only.
  • For autoFocus, use ui:autofocus.
  • For data, set the options in the schema, with enum or oneOf.
  • For multiple, make the field's schema an array of files.
  • For error, add the message with the Form's extraErrors or customValidate, and hide messages with ui:hideError.
  • For onChange, onBlur and onFocus, use the Form's props of the same name.

@rjsf/mui's widgets keep the form's value, disabled state and handlers over ui:options.mui BREAKING CHANGE​

This affects a field that sets disabled, onChange, onBlur, onFocus, value or checked in ui:options.mui or ui:globalOptions.mui. They replaced what the form sets on MUI's controls: an onChange was called in place of the form's, so editing the field never changed the form's data, and a disabled: false let a user edit a disabled or read-only field.

All four are now ignored where MUI uses them in place of the form's:

  • a field drawn by BaseInputTemplate, such as a text, number or date input: the top level of ui:options.mui, and slotProps.input
  • SelectWidget: the top level, rjsfSlotProps.select and rjsfSlotProps.input, an input element in rjsfSlotProps.select, which is ignored whole as it would replace the input the field draws, and the SelectDisplayProps inside rjsfSlotProps.select, rjsfSlotProps.htmlInput and the inputProps and slotProps.input of rjsfSlotProps.select and rjsfSlotProps.input, which also keeps the field's id on the combobox
  • CheckboxWidget: rjsfSlotProps.checkbox
  • RangeWidget: the top level and rjsfSlotProps.slider, and the onBlur and onFocus of rjsfSlotProps.slider.slotProps.root

Only disabled is ignored where MUI calls an onChange, onBlur or onFocus beside the form's, so those keep working:

  • BaseInputTemplate: slotProps.root, slotProps.htmlInput, and the inputProps and slotProps.input inside slotProps.input
  • SelectWidget: rjsfSlotProps.root, rjsfSlotProps.htmlInput, and the inputProps and slotProps.input inside rjsfSlotProps.select and rjsfSlotProps.input
  • CheckboxWidget: the top level and rjsfSlotProps.formControlLabel, which go on the <label>, so its onBlur and onFocus run as focus moves through the box (MUI already ignored an onChange there), and rjsfSlotProps.checkbox.slotProps.input
  • CheckboxesWidget: rjsfSlotProps.formControlLabel, as on CheckboxWidget, and rjsfSlotProps.checkbox.slotProps.input
  • RadioWidget: rjsfSlotProps.radio.slotProps.input
  • RangeWidget: rjsfSlotProps.slider.slotProps.input

The widgets also ignore a value or checked there that would replace the form's data, which left the field showing a value the form didn't hold:

  • BaseInputTemplate: a value at the top level, in slotProps.input and slotProps.htmlInput, and in the inputProps and slotProps.input inside slotProps.input
  • SelectWidget: a value at the top level, in rjsfSlotProps.select, rjsfSlotProps.input and rjsfSlotProps.htmlInput, and in the inputProps and slotProps.input of rjsfSlotProps.select and rjsfSlotProps.input
  • CheckboxWidget: a checked in rjsfSlotProps.checkbox and rjsfSlotProps.checkbox.slotProps.input
  • CheckboxesWidget: a checked or value in rjsfSlotProps.checkbox.slotProps.input
  • RadioWidget: a checked or value in rjsfSlotProps.radio and rjsfSlotProps.radio.slotProps.input, and a checked in rjsfSlotProps.formControlLabel
  • RangeWidget: a value in rjsfSlotProps.slider.slotProps.input

An inputProps inside the input slot still replaces the whole htmlInput slot, as MUI does, so the <input> loses the attributes the field gives it there, such as aria-describedby, required, min and max; give the <input>'s props through htmlInput instead.

A disabled or read-only SelectWidget also ignores an open or defaultOpen in rjsfSlotProps.select, rjsfSlotProps.htmlInput or the inputProps or slotProps.input of either slot, and an open in their MenuProps, since MUI lets a user pick an option from a menu held open. It ignores a tabIndex there, or in a SelectDisplayProps inside them, too, since MUI lets a user open a focused select's menu from the keyboard even while disabled.

  • For disabled, use ui:disabled or ui:readonly.
  • For onChange, onBlur and onFocus, use the Form's props of the same name.
  • For value and checked, give the field's value through the Form's initialFormData.
const uiSchema = {
pet: {
- 'ui:options': { mui: { disabled: true, onChange: logChange } },
+ 'ui:disabled': true,
},
};
+
+<Form schema={schema} uiSchema={uiSchema} validator={validator} onChange={logChange} />;

@rjsf/mui's descriptions render a div rather than an <h6> BREAKING CHANGE​

This affects a style or a test that finds a field's description, or a selected option's, as an h6 or by the heading role. DescriptionField rendered each description as an <h6> heading, which listed every description among the form's headings. It now renders a div with the same look. To get the <h6> back, give the description's Typography a component, in a field's ui:options or for every field in ui:globalOptions (a field with its own ui:options.mui needs it there too):

const uiSchema = {
'ui:globalOptions': {
+ mui: { rjsfSlotProps: { descTypography: { component: 'h6' } } },
},
};

An object's or an array's group has role="group" and the field's id BREAKING CHANGE​

This affects a style or a test that finds a container's parts by where they sit in the DOM, by the group role, or by the field's id. An object's or an array's title, description and contents are now a group, named by the title and described by the field's errors, description and help, so a screen reader reads them together (#5723):

  • In @rjsf/chakra-ui, @rjsf/fluentui-rc, @rjsf/mui and @rjsf/react-bootstrap, ObjectFieldTemplate rendered them with no element around them, and in @rjsf/fluentui-rc so did ArrayFieldTemplate. They now render inside a div with role="group" carrying the field's id.
  • @rjsf/daisyui's ArrayFieldTemplate and ObjectFieldTemplate, @rjsf/mantine's ObjectFieldTemplate, and the ArrayFieldTemplate of @rjsf/chakra-ui, @rjsf/mui and @rjsf/react-bootstrap give the element they already rendered role="group", and the field's id where it didn't carry it.
  • A <fieldset>, which every other container renders, is a group already, and only gains the name and description.

Point a child selector at the new div, as in [role="group"] > .... A test calling getByRole('group') can now find the root object's group as well, so give it the group's name or search within the field:

-screen.getByRole('group');
+screen.getByRole('group', { name: 'Pets' });

AltDateWidget date parts are named in every theme BREAKING CHANGE​

Each select an AltDateWidget (and AltDateTimeWidget) renders is a control of its own: DateElement gives it the date part's id, such as root__year, as both its id and its name, and the name prop it used to take is ignored and deprecated. Each part is named by the field's label and the part, such as When, year, in every theme, @rjsf/mantine included, even when the field hides its label, so the parts of two date fields stay distinct; only a field with no label at all leaves the part alone, such as year (#5355). Each part is also described by the field's description, help and errors, which no part was linked to before. A test querying a part by its own name, or by the value it displays, no longer matches when the field has a label:

-screen.getByRole('combobox', { name: 'year' });
+screen.getByRole('combobox', { name: 'When, year' });

The parts' names and placeholders go through translateString as TranslatableString.YearLabel, MonthLabel, DayLabel, HourLabel, MinuteLabel and SecondLabel, and the way the field's label and the part are combined goes through TranslatableString.DateElementAriaLabel ('%1, %2'), so a locale can reorder them or change the separator (#5357). Their english defaults are the lowercase part names the widgets displayed before, so a form without a custom translateString displays the same text.

DateElement hands the name and the description to the registry's SelectWidget as its aria-label and aria-describedby, which every theme's SelectWidget now gives to the control a screen reader focuses through the new widgetAriaProps(), describing it by the caller's ids and, while it shows one, by the id of the selected option's description, the one element it renders itself, listed where it is drawn among the field's description and help. A replacement SelectWidget must do the same, or the date parts it renders are left unnamed and undescribed; no other widget is required to honor the two props:

import { widgetAriaProps } from '@rjsf/utils';

function MySelectWidget(props: WidgetProps) {
return <select id={props.id} {...widgetAriaProps(props)}>{/* ... */}</select>;
}

Three theme changes follow from this. @rjsf/mui's SelectWidget describes its combobox rather than the field's root, and an aria-label or aria-describedby set in an htmlInput given through rjsfSlotProps still wins over both. @rjsf/fluentui-rc's SelectWidget no longer renders an empty <label> for a field with no label, which named the dropdown by nothing. @rjsf/shadcn's single SelectWidget trigger is a combobox, which holds the option it displays as its value, so a date part named by its aria-label still announces its selected option.

A theme or form whose FieldTemplate draws the description below the select or the help above it declares the order in fieldTemplateOrder in its presentation, and one whose SelectWidget draws the field's description itself, with its selected option's description before it, declares selectedOptionDescription: 'beforeDescription', so a screen reader announces them in the order they are shown (#5367, #5720).

widgetAriaProps() also names a select no label names, such as an untitled root or array item select, by its ui:placeholder, else TranslatableString.SelectPlaceholder ("Select ..."), since a select shows its value rather than a name (#5711). A replacement SelectWidget that draws its field's label itself, as widgetDrawsLabelText(props) says, takes no such name, since that label names the select; one whose plan has it draw its label must draw it, or the select is left with no name. So must a custom ui:field that hands its presentation on to the registry's SelectWidget, since that plan has the field draw the label: it draws one or passes the widget an aria-label. getGroupAttributes() names a radiogroup no label names the same way, by its ui:placeholder, else TranslatableString.RadioGroupName ("Choose an option"), where the widget passes it its label, placeholder and registry, as every theme's RadioWidget does; a replacement RadioWidget should pass them too (#5586). controlNameTests(Form) from @rjsf/snapshot-tests checks all of these against a theme's Form.

AltDateWidget hands an unset date part on as undefined, and useAltDateWidgetProps() returns buttonsDisabled BREAKING CHANGE​

useAltDateWidgetProps()'s elements give a part nobody has picked yet an undefined value, where the DateObject behind them holds -1, and DateElement hands the SelectWidget undefined for a -1 it is given (#5378). Every theme's parts now report it to onFocus and onBlur as undefined, as @rjsf/core's, @rjsf/react-bootstrap's and @rjsf/mantine's already did, where @rjsf/chakra-ui's reported '' and the other themes' -1. A replacement SelectWidget, a custom AltDateWidget that renders its own controls from elements, or an onBlur handler, that tells an unset part by '' or -1 should tell it by undefined.

useAltDateWidgetProps() returns buttonsDisabled, true when the widget is disabled or read-only, a required member of UseAltDateWidgetResult, so a hand-built one, such as a mocked return of the hook, has to include it (#5378).

enumOptionValueEncoder() is removed in favor of enumOptionsDomValues() BREAKING CHANGE​

enumOptionValueEncoder() encoded one option at a time, but in the realValue format an option's DOM value now depends on the rest of the list: options whose String() is the same, such as 1 and '1' or true and 'true', used to share a DOM value, so the second of them could not be picked and picking it wrote the first one's value (#5315). The new enumOptionsDomValues() encodes the whole list in one pass, giving each of those options its prefixed index (__rjsf_index:1) instead, and enumOptionValueDecoder() and enumOptionSelectedValue() encode against the list the same way. It also encodes an object, array, null or empty string option, and a string starting with the prefix, as its index prefixed with __rjsf_index:, where v6 encoded objects and arrays as the bare index and null as the empty string, so code that parses these DOM values itself needs to expect the prefix (#5309).

A custom widget encodes its options once per render and reads each option's value at its position:

// v6
value={enumOptionValueEncoder(option.value, index, optionValueFormat)}
// v7
const domValues = enumOptionsDomValues(enumOptions, optionValueFormat);
// ...
value={domValues[index]}

For options from groupEnumOptions(), read domValues[option.index], since index is the option's position in the ungrouped list.

@rjsf/utils schema functions take a SchemaContext in place of the validator BREAKING CHANGE​

The validator-based schema functions in @rjsf/utils used to take the customMergeAllOf and defaultFormStateBehavior of a form as separate parameters. Each of those functions had to copy them, one by one, into every schema function it called, and several calls left one out, so a customMergeAllOf silently stopped applying partway down a call chain (#4383, #4385).

Those settings now travel together with the validator in a SchemaContext:

interface SchemaContext<S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = FormContextType> {
readonly validator: ValidatorType<S, F>;
readonly customMergeAllOf?: CustomMergeAllOf<S>;
readonly defaultFormStateBehavior?: DefaultFormStateBehavior;
}

Every function that took a validator takes a SchemaContext in its place, and passes it along whole to every schema function it calls, so dropping the customMergeAllOf would also mean dropping the validator. The separate customMergeAllOf and defaultFormStateBehavior parameters are gone, and the parameters that followed them move up a position; every other parameter, rootSchema included, is unchanged:

-const resolvedSchema = retrieveSchema(validator, schema, rootSchema, formData, customMergeAllOf);
-const isSelectField = isSelect(validator, schema, rootSchema, customMergeAllOf);
+const context: SchemaContext = { validator, customMergeAllOf, defaultFormStateBehavior };
+const resolvedSchema = retrieveSchema(context, schema, rootSchema, formData);
+const isSelectField = isSelect(context, schema, rootSchema);
Functionv6 parametersv7 parameters
findFieldInSchema()validator, rootSchema, schema, path, formData?, customMergeAllOf?context, rootSchema, schema, path, formData?
findSelectedOptionInXxxOf()validator, rootSchema, schema, fallbackField, xxx, formData?, customMergeAllOf?context, rootSchema, schema, fallbackField, xxx, formData?
getClosestMatchingOption()validator, rootSchema, formData, options, selectedOption?, discriminatorField?, customMergeAllOf?context, rootSchema, formData, options, selectedOption?, discriminatorField?
getDisplayLabel()validator, schema, uiSchema?, rootSchema?, globalOptions?, customMergeAllOf?context, schema, uiSchema?, rootSchema?, globalOptions?
getFirstMatchingOption()validator, formData, options, rootSchema, discriminatorField?context, formData, options, rootSchema, discriminatorField?
getFromSchema()validator, rootSchema, schema, path, defaultValue, customMergeAllOf?context, rootSchema, schema, path, defaultValue
getUiRequiredErrorSchema()new in v7context, rootSchema, uiSchema, formData, uiSchemaDefinitions?, globalUiOptions?, formContext?, fieldState?
isFilesArray()validator, schema, uiSchema?, rootSchema?, customMergeAllOf?context, schema, uiSchema?, rootSchema?
isMultiSelect()validator, schema, rootSchema?, customMergeAllOf?context, schema, rootSchema?
isSelect()validator, theSchema, rootSchema?, customMergeAllOf?context, theSchema, rootSchema?
omitExtraData()validator, schema, rootSchema?, formData?, customMergeAllOf?context, schema, rootSchema?, formData?, fieldState?
retrieveSchema()validator, schema, rootSchema?, rawFormData?, customMergeAllOf?, resolveAnyOfOrOneOfRefs?context, schema, rootSchema?, rawFormData?, resolveAnyOfOrOneOfRefs?, selectedOption?
sanitizeDataForNewSchema()validator, rootSchema, newSchema?, oldSchema?, data?, customMergeAllOf?context, rootSchema, newSchema?, oldSchema?, data?

retrieveSchema() is the one to double check: a v6 call passing resolveAnyOfOrOneOfRefs passed customMergeAllOf (or undefined) before it, so drop that argument rather than only replacing the validator.

getDefaultFormState() is the exception: it had the longest list of optional parameters, most of them easy to pass in the wrong order, so it now takes a SchemaContext followed by a GetDefaultFormStateProps object:

-getDefaultFormState(validator, schema, formData, rootSchema, false, defaultFormStateBehavior, customMergeAllOf, undefined, uiSchema);
+getDefaultFormState({ validator, customMergeAllOf, defaultFormStateBehavior }, { schema, formData, rootSchema, uiSchema });

Its props are schema, formData?, rootSchema?, includeUndefinedValues?, initialDefaultsGenerated?, uiSchema? and uiSchemaDefinitions?, with the same meanings and defaults the positional parameters had.

createSchemaUtils() and SchemaUtilsType.doesSchemaUtilsDiffer() take a SchemaContext and the rootSchema:

-const schemaUtils = createSchemaUtils(validator, rootSchema, defaultFormStateBehavior, customMergeAllOf);
+const schemaUtils = createSchemaUtils({ validator, defaultFormStateBehavior, customMergeAllOf }, rootSchema);

SchemaUtilsType's own methods supply the context and root schema themselves, so they are unchanged. It also gains constrainsAlike(oldSchema, newSchema, oldInheritedDefault, newInheritedDefault, oldPlacement, newPlacement) and getFilledDefault(schema, inheritedDefault, placement), which read a field's defaults under the form's own defaultFormStateBehavior, so a SchemaUtilsType implementation of your own needs the two new methods.

validateFormData(), isValid() and rawValidation() of ValidatorType take no SchemaContext, as in v6. rawValidation() changed in another way; see any members of the public types are specific types. A custom validator still has to change for this release, since isValid() is now handed { $ref } pointers into the rootSchema it is passed; see Validators are handed pointers into the root schema. The context carries a validator, so handing one to a validator would have meant every implementation discarding the validator it was given in favor of itself; the two things a validator actually needed the settings for are supplied another way instead.

validateFormData() takes one new optional trailing parameter, getCustomValidateFormData: a function returning the form data to hand customValidate, with the defaults already applied. Form supplies it from its own SchemaUtils, so those defaults honor the form's customMergeAllOf and defaultFormStateBehavior, which they never did before. Left out, the validator computes them itself exactly as it did in v6, so a direct caller keeps working:

validator.validateFormData(formData, schema, customValidate, transformErrors, uiSchema);
+validator.validateFormData(formData, schema, customValidate, transformErrors, uiSchema, () => schemaUtils.getDefaultFormState(schema, formData, true, undefined, uiSchema));

It is a function rather than the data itself for two reasons: defaults that legitimately come out undefined are then an answer the validator can tell apart from "nothing was supplied", and an implementation that computes the defaults its own way never makes the caller compute them as well.

A precompiled validator takes the form's customMergeAllOf at construction rather than per call, since it is tied to one root schema compiled once:

-const validator = createPrecompiledValidator(validateFns, schema);
+const validator = createPrecompiledValidator(validateFns, schema, { customMergeAllOf });

It is used when the validator resolves its own root schema to check the schema it is handed, which is what previously failed with "The schema associated with the precompiled validator differs from the rootSchema provided for validation" for a form with a customMergeAllOf and an allOf in its root schema. compileSchemaValidators() and compileSchemaValidatorsCode() take no customMergeAllOf: they compile the subschemas the root names, and a schema a merge makes is not compiled (see Validators are handed pointers into the root schema).

The computeSkipPopulate() callback of defaultFormStateBehavior.arrayMinItems receives the SchemaContext in effect in place of the validator, so any schema function it calls can be handed the form's customMergeAllOf too:

-computeSkipPopulate: (validator, schema, rootSchema) => !isSelect(validator, schema.items, rootSchema),
+computeSkipPopulate: (context, schema, rootSchema) => !isSelect(context, schema.items, rootSchema),

The helpers @rjsf/utils exported only through deep imports of lib/schema/*, such as computeDefaults() and calculateIndexScore(), can no longer be imported, since deep imports are removed.

Four places that used to drop a setting now apply it:

  • SchemaUtilsType.omitExtraData() never passed the form's customMergeAllOf on, so the allOfs it merged were always merged the default way.
  • An allOf reached through a $ref option of a oneOf inside dependencies was always merged the default way.
  • The defaults handed to customValidate were computed with neither the customMergeAllOf nor the defaultFormStateBehavior of the form.
  • The precompiled validators of @rjsf/validator-ajv8 and @rjsf/validator-ata check the schema they are handed against their root schema, resolving that root schema when it isn't an exact match. They resolved it with the default allOf merge, so under liveValidate a form with a customMergeAllOf and an allOf in its root schema failed with "The schema associated with the precompiled validator differs from the rootSchema provided for validation". They now resolve it with the customMergeAllOf they were constructed with, so the error no longer comes from validateFormData(), nor from a schema function scoring oneOf/anyOf options or resolving a condition against that root schema.

A form that sets either of them sees it applied in those places from now on.

With a precompiled validator, give the form's customMergeAllOf to createPrecompiledValidator(), as above, so the root-schema check resolves with the merge the form resolves with.

findSelectedOptionInXxxOf() returns the closest option for options with no discriminator BREAKING CHANGE​

findSelectedOptionInXxxOf() returned undefined for options with no discriminator unless the data held the value of a default or const one of them declared (#5392). It now returns the option getClosestMatchingOption() picks for the data, as MultiSchemaField does, and no longer reads fallbackField. With no data that is the first option, and a tie goes to the first option the data matches, or to the first option when it matches none. Options with a discriminator read as before, so a value none of them holds selects nothing.

What to change: code that reads undefined as "no option is selected" for such options now gets an option, so it should look for what it wants in that option. Options told apart by the value of a property should name it as their discriminator, the only property read by value now.

const option = schemaUtils.findSelectedOptionInXxxOf(schema, 'a', 'anyOf', formData);
-if (option) {
+if (option?.properties?.a) {

A LayoutGridField cell naming a property beside anyOf/oneOf, or of options with no discriminator, renders the field BREAKING CHANGE​

A LayoutGridField cell { name: 'a', render: Comp } whose name is a property declared beside anyOf/oneOf, or a property of options with no discriminator, showed Comp, since the grid read the cell only from the selected option and found no schema for it (#5392). The grid now reads a property beside the options from there, and a property of the options from the option the form shows, so the cell renders the field and leaves Comp out, as a cell naming a plain property does. To keep showing Comp, name no property:

-{ name: 'a', render: Comp }
+{ name: '', render: Comp }

A LayoutGridField on the schema holding options with no discriminator is the exception: while MultiSchemaField renders the options beside the grid, the option there renders its own properties, so a cell naming one of them still renders nothing, or its render. For { properties: { x: { type: 'string' } }, anyOf: [{ properties: { a: { type: 'string' } } }, { properties: { b: { type: 'number' } } }] } with { a: 'q' }, a row of x and a renders root_x, then the option selector and the option's root_a.

To have the grid lay out the option's properties instead, set ui:fieldReplacesAnyOrOneOf so the grid replaces the options. It then renders no option selector and the data decides which option its cells read when the form gets it, which then holds while the user edits a cell, as it does outside a grid, so a user can't choose another option, and a row without b shows nothing for { b: 1 }:

{
'ui:field': 'LayoutGridField',
+ 'ui:fieldReplacesAnyOrOneOf': true,
'ui:layoutGrid': { /* ... */ },
}

A LayoutGridField cell naming a property of discriminated options beside the grid renders nothing BREAKING CHANGE​

A LayoutGridField on the schema holding discriminated anyOf/oneOf options, without ui:fieldReplacesAnyOrOneOf, rendered a cell for the selected option's property while MultiSchemaField rendered the options beside the grid, so two inputs shared an id (#5747). The option beside the grid now renders its property alone, and the cell renders nothing, or its render, as it does for options with no discriminator. For { discriminator: { propertyName: 'kind' }, properties: { x: { type: 'string' } }, oneOf: [{ properties: { kind: { const: 'a' }, a: { type: 'string' } } }, { properties: { kind: { const: 'b' }, b: { type: 'number' } } }] } with { kind: 'a', a: 'q' }, a row of x and a renders root_x, then the option selector and the option's root_a.

What to change: to have the grid lay out the option's properties, set ui:fieldReplacesAnyOrOneOf so the grid replaces the options, as for options with no discriminator. Otherwise remove a cell naming only the property, which renders nothing; a { name, render } cell still shows its render.

A property declared beside anyOf/oneOf and in an option is read from beside them BREAKING CHANGE​

findFieldInSchema() and a LayoutGridField cell read a property that a schema declares both beside its anyOf/oneOf and in an option from the option. They now read it from beside the options (#5392).

For { properties: { kind: { enum: ['a', 'b'] } }, oneOf: [{ properties: { kind: { const: 'a' } }, required: ['kind'] }, { properties: { kind: { const: 'b' } } }] } with { kind: 'a' }, findFieldInSchema() returned { const: 'a' } as required, and now returns the enum as not required, and a grid cell renders the enum.

What to change: declare the property in one place, or add it to the required list beside the options. A property only an option declares is read as before.

{
properties: { kind: { enum: ['a', 'b'] } },
+ required: ['kind'],
oneOf: [
{ properties: { kind: { const: 'a' } }, required: ['kind'] },
{ properties: { kind: { const: 'b' } } },
],
}

A custom ObjectFieldTemplate asks registry.schemaUtils.canExpand() BREAKING CHANGE​

Whether an object naming no additionalProperties takes extra keys is now a form setting, additionalPropertiesWhenAbsent, which travels in the SchemaContext (#2137). SchemaUtilsType gains canExpand(schema, uiSchema, formData) and allowsAdditionalProperties(schema), which pass the form's own SchemaContext, every theme's ObjectFieldTemplate now calls registry.schemaUtils.canExpand(), and @rjsf/utils no longer exports canExpand() itself (see Utilities no other RJSF package uses are no longer exported from @rjsf/utils).

A custom ObjectFieldTemplate does the same:

// v6
{canExpand(schema, uiSchema, formData) && <AddButton onClick={onAddProperty} />}
// v7
{registry.schemaUtils.canExpand(schema, uiSchema, formData) && <AddButton onClick={onAddProperty} />}

A SchemaUtilsType implementation of your own needs the two new methods.

An $id under an unknown keyword, such as components or x-*, names no schema BREAKING CHANGE​

Embedded schemas are found by $id through the schema's structure: only the keywords that hold a schema, an array of schemas or a map of names to schemas are walked. Under a container that is none of these, such as an OpenAPI-style components block or an x-* vendor key, an $id is data. It is no longer found as an embedded schema, and it no longer changes what the $refs beside and below it resolve against, also where a $ref reaches the schema holding it. A schema such a $ref reaches is still read as a schema, and its own $refs resolve against the resource holding the container, so the usual OpenAPI layout, with components at the root and #/components/... refs, keeps working. Only one case breaks: a $ref to an $id declared inside the container, which now throws Could not find a definition for .... If a schema relies on it, move those schemas under $defs (draft 2020-12) or definitions (draft 7) so the walk reaches them. A $ref beside such an $id resolves where it threw: with $defs: { real: { $id: 'n.json', type: 'boolean' }, s: { type: 'string' } }, components: { Pet: { type: 'object', properties: { n: { $id: 'n.json', $ref: '#/$defs/s' } } } } and properties: { pet: { $ref: '#/components/Pet' } } under a root whose $id is https://e.com/root.json, root_pet_n is a string field, where the form threw Could not find a definition for #/$defs/s..

One symptom is worth knowing: AJV collects $ids by walking every key, so it still finds an $id kept under components or x-*. A form whose schema relies on that passes validation but throws Could not find a definition for ... when it renders.

localToUTC() can return null BREAKING CHANGE​

localToUTC() takes unknown and returns string | null | undefined, where its inferred return type was string | undefined. null isn't a new result: localToUTC() has always returned it for a value Date can't read, since toJSON() gives null for an invalid date though its type says string. A value Date throws for, such as a bigint or a symbol, now gives null as well instead of throwing. Code that assigns the result to string | undefined no longer compiles; map null to the value it should store:

// Before
const utc: string | undefined = localToUTC(value);

// After
const utc: string | undefined = localToUTC(value) ?? undefined;

A non-object field's anyOf/oneOf option renders with its parent's keywords BREAKING CHANGE​

A field with anyOf/oneOf options is rendered entirely by its selected option, unless it is an object, whose own ObjectField renders its properties beside the option. A type list naming object, such as ['object', 'string'] or a nullable ['object', 'null'], counts as an object only while the option shown can hold one, so an option of another type, a null option included, renders the whole field wherever object sits in the list rather than beside the object's properties (#5725). The option used to inherit only the field's required list and type, so anything else the field declared never reached the control that renders it (#5359). An array whose options only limit its length rendered "Unsupported field schema ... Missing items definition." in place of its items, and a format: 'date' string rendered a plain text input.

{
"type": "array",
"items": { "type": "string", "enum": ["a", "b"] },
"uniqueItems": true,
"oneOf": [{ "minItems": 1 }, { "maxItems": 1 }]
}

The option of a field that is not an object now inherits every keyword of the field that describes its value, so this schema renders a multi-select. That includes keywords that change how a field that already rendered looks, such as format, enum, examples and readOnly, so a format: 'date' string now renders a date input. Switching to another option checks the value against that option as it renders, inherited keywords included, so an array whose options only limit its length keeps its items, where checking it against the bare option, which is no array, cleared them. A value that is neither an array nor an object, such as a string, is still cleared by every switch, even between two options that both describe it. A tuple keeps its items the same way, each sanitized against the schema the selected option declares at its position, unless the selected option gives a prefix position a type that leaves out one the other option gave it, which clears the array; an item past them is dropped instead. An array the selected option has a const for is replaced with that const when it doesn't equal it, as one the option selects whole arrays from with an enum is cleared when the enum doesn't list it, unless the option switched away from declared the same const or enum, which already rejected the array. Where both options render the items alike, with the same uiSchema and a field per item, and the switch leaves the array's length as it was, an item field's own errors stay with the item, as its field stays in place. An option that is a select over whole arrays renders one control for the array, and one holding options of its own renders its items anew, so a switch to or from either drops them. The field's title, description, deprecated and default stay with the field rather than being repeated on the option. The field's own template labels it and draws its description whichever option is selected, an array or a checkbox included, and the option's field draws its own title and description only where they say something else, as a field's label, description, help and errors are drawn where its plan says describes. A bound both the field and the option declare, such as maxItems, maxLength or minimum, keeps the stricter of the two, since a value has to meet both: an option's maxItems: 5 beside the field's maxItems: 3 stops Add at three items, and switching to that option trims the array to three. An option's true subschema, such as items: true, adds nothing to meet, so it leaves the field's subschema in place rather than rendering every item as an unsupported field, a tuple included. An option's items schema beside the field's tuple applies to each of its positions, and to the items past it where the tuple declares additionalItems; a tuple declaring none stays at its length, as ArrayField offers it no Add, and so does an option's tuple beside the field's items schema. A field's false subschema, which nothing meets, stays in place of the option's in the same way. An enum the option declares is its own list as it stands, since its ui:enumNames are read by position against it: a value the field's enum doesn't list stays, and validation rejects it. Two required lists ask for every name either does. A not, or a conditional (if, then and else together), that the option declares replaces the field's as a whole, rather than being merged with it into one neither declared. Any other keyword both declare, such as a format or a pattern, is the option's. A subschema either declares through a $ref, such as items: { "$ref": "#/$defs/short" }, is left to the field's allOf merge, which meets the two once the $ref is resolved: met before it, the keywords beside the $ref would be layered over the ones it names, and a looser bound would win. ui:required is checked against the option as it renders, too: a ui:required field within the field's items is checked where an option's items: true leaves them in place, and one the field's false subschema forbids is not. The form's defaults are computed from the option as it renders as well, so an option's minItems: 1 beside the field's minItems: 2 starts the form with two items, each holding the defaults of the field's items, whether the option is written in place or as a $ref, and a tuple's options add to the defaults of its positions. An option written as a $ref is followed only to the schema it names, so an allOf within it is still left to the allOf defaults behavior. One that inherits nothing from the field, such as the option of a typeless field, is computed as before, taking the field's default only when there is no data; one that inherits something takes the field's default as an option written in place does, beneath its data and its own default. A field that is not an object and declares both anyOf and oneOf renders only the selected option of the keyword SchemaField reads, the anyOf unless it is empty, so its defaults are that option's alone, where they used to include those of the other keyword's selected option too. An object field still adds both. The bounds are read as draft-06 and later define them, where exclusiveMinimum and exclusiveMaximum are numbers of their own. The draft-04 form, a boolean that makes minimum or maximum exclusive, is not supported: beside a numeric bound from the other schema it can make a bound exclusive that neither schema did, as with a field's maximum: 3 and an option's { maximum: 5, exclusiveMaximum: true }, which renders as less than 3. A schema written for draft-04 or OpenAPI 3.0 should use the numeric form, exclusiveMaximum: 5 in place of maximum: 5, exclusiveMaximum: true.

An object that names no type of its own, but has properties, now passes object on to options that name no type either, as a type: 'object' field already did. Its options used to render "Unsupported field schema ... Unknown field type undefined."; they now render as the typed field's do.

An option selector stays a select for a text, updown or range ui:widget its options inherit BREAKING CHANGE​

This affects a field with non-constant anyOf/oneOf options, a ui:widget of text, updown or range (or TextWidget, UpDownWidget or RangeWidget), and at least one option with no uiSchema entry of its own. The option selector rendered that widget too, showing the selected option's index as a number to edit. It now stays a select, and the widget renders only on an option that inherits it. Any other widget, such as radio, stays on the selector as it was.

To keep such a widget on the selector, give every option a uiSchema entry, so that none of them inherits it:

const uiSchema = {
'ui:widget': 'range',
+ anyOf: [{}, {}],
};

See Multiple types for details.

An anyOf/oneOf option naming no type takes its field's type for the Optional Data Controls BREAKING CHANGE​

The Optional Data Controls (enableOptionalDataFieldForType) read the type of a field with anyOf/oneOf options from the options alone, so a field whose options named no type of their own never got them, whatever its own type said (#5360).

{
"type": "object",
"properties": { "a": { "type": "string" }, "b": { "type": "string" } },
"oneOf": [{ "required": ["a"] }, { "required": ["b"] }]
}

An option naming no type now takes the field's own, as the option MultiSchemaField renders already did, so with object enabled this optional field renders behind an Add button, and nothing else until it is clicked, where it used to show its fields and the option selector straight away. An optional array whose anyOf options are minItems/maxItems bounds already rendered an Add button with array enabled, since its option took the array's type, but showed the option selector beside it; now it hides the selector until Add is clicked, unless its defaults fill it, as the first option's minItems: 1 does under the default arrayMinItems, which renders it with its items and a Remove button, as a plain minItems array does. Options naming several types still get none, a null option beside another type included, since such a list keeps the selector the null branch is reached through, which the controls would hide: { "type": ["object", "null"], "anyOf": [{ "required": ["a"] }, { "type": "null" }] } renders as before, while the same field with every option typeless resolves to object and gets the controls. ui:required follows: a ui:required property of such an object is not reported while the object has no value, since its properties are hidden behind the Add button then. So do the form's defaults: while such an object has no value, none of the nulls of its type: 'null' properties is filled in, so it stays out of the defaults. To keep the old rendering, leave the field's type out of enableOptionalDataFieldForType, or set ui:enableOptionalDataFieldForType to [] in that field's uiSchema to turn the controls off for that field alone. Marking the field ui:required would render it as before too, but it also keeps the form from submitting while the field is empty.

An empty object counts as added data for the Optional Data Controls BREAKING CHANGE​

With enableOptionalDataFieldForType naming object, an optional object holding {} counted as having no data, so it rendered behind its Add button, and an Add that stored {}, as one for an object with only additionalProperties does, changed nothing (#5427). isFormDataAvailable() now returns true for {}, so only undefined and null count as no data. An object holding {} renders its fields and a Remove button, a ui:required property of it is reported, and its type: 'null' properties get their nulls in the defaults, as any added object's do. An anyOf/oneOf switch no longer writes {} for an optional object in the new option, array items included, that the old data held nothing for, so it stays behind its Add button, as when the form mounts on that option. An object with anyOf/oneOf options renders one set of controls, its own, in its heading beside the options, root_n__XxxOf__optionalAdd. The selected option drew a second set, root_n__optionalAdd, which an object with neither properties of its own nor room for more keys besides its options still draws, since nothing renders beside them. Until Add, the selected option of an object with properties of its own or room for more keys renders nothing, where it drew an empty frame titled after the option, or its fields where its own uiSchema turned the controls off, so typing in them added the object. With neither an option selector nor an option to show, the field renders no MultiSchemaFieldTemplate at all, so a custom one that draws something around its selector and optionSchemaField, such as a wrapper or a heading, renders it only after Add. omitExtraData still drops an optional object holding {}, so with it set an added object that holds nothing goes back behind its Add button on submit, and under liveOmit="onChange" its Add still does nothing. To keep an optional object behind its Add button, leave it out of the data rather than setting it to {}:

- initialFormData={{ meta: {} }}
+ initialFormData={{}}

Code that called isFormDataAvailable() to tell an empty object from a filled one should check Object.keys(formData).length > 0 itself.

A field's label, description, help and errors are drawn where its plan says BREAKING CHANGE​

SchemaField now plans each field before rendering it (#5490): from the field and widget it renders, and the presentation the registry merges from @rjsf/core, the theme and the Form, it decides who draws the field's label, description, help and errors, under which ids, and the attributes its control carries. The plan reaches the FieldTemplate, the field and its widget as a presentation prop, as described in resolveFieldPresentation(), and the props templates and widgets already read are now derived from it. displayLabel, rawErrors, hideLabel and getDisplayLabel() are marked @deprecated and kept through v7 for a template or widget rendered without a plan; read the plan instead, or templateDrawsLabel(), widgetDrawsLabel() and hasWidgetErrors(), which fall back to them only without one. The rawErrors a field and an ArrayFieldTemplate or ObjectFieldTemplate are handed, which carry the errors whatever hideError says, and hasVisibleErrors(), which applies hideError to them, are marked @deprecated too, with VisibleErrorsProps, its parameter type (#5325): the plan's presentation.errors.messages has ui:hideError applied, and a widget or container template draws them only where presentation.errors.by is 'self', since the template draws them under 'template', which is what the new getErrorsToDraw() gives it; a field reads presentation.errors.messages itself, since getErrorsToDraw() gives a field that draws its own errors none, so that a widget it spreads its plan into doesn't draw them again; for an error state, read hasWidgetErrors() for a widget's control and fieldShowsOwnErrors() for the label or description a widget draws for its field, hasErrors in a FieldTemplate, ArrayFieldTemplate or ObjectFieldTemplate, or presentation.errors.own in a field. All four stay through v7, and the props are still handed over in full, for a component that renders the hidden errors its own way. With a plan, the plan wins: core's and every theme's templates and widgets ignore a hideLabel, displayLabel or rawErrors handed alongside one, so a custom field that renders a widget with its plan and hideLabel to hide the widget's label should hand it a plan whose label isn't the widget's instead:

  • displayLabel is true only where the template draws the label. In a theme whose widgets draw their own label, as @rjsf/mui's, @rjsf/fluentui-rc's, @rjsf/chakra-ui's and @rjsf/mantine's do, it is false for nearly every field, so a custom FieldTemplate there should no longer gate the description on it. Read it through templateDrawsLabel(), which reads the plan and falls back to displayLabel only for a template rendered without one.
  • ArrayFieldItemTemplateProps.displayLabel says whether a label sits above the item's control, from the plan the item's SchemaField builds, where it was getDisplayLabel() of the item's schema and its own uiSchema: a label its template draws, which is the displayLabel the item's own FieldTemplate is handed, or one its field or widget draws itself where its declaration places it labelPlacement: 'above', as @rjsf/fluentui-rc, @rjsf/chakra-ui and @rjsf/mantine declare their widgets' labels. So the item's buttons are placed by the label above its control. It is now false for an item with no label text, a null item, a hidden item, a boolean under ui:widget: 'checkbox', and a custom field drawing its own label whichever of ui:field or a $id names it, unless its declaration places that label above, and true for a custom field declared { label: 'template' }, a boolean whose titled oneOf constants render a select, and an object anyOf/oneOf, whose selector its template labels, as does an item the fallback UI (useFallbackUiForUnsupportedType) renders with a choice of types, whose type selector comes first and is labelled for the type whatever the item's title, unless ui:label is false or the item is hidden, which hides the selector with it. In @rjsf/mui, whose text inputs and selects draw their label inside the input's outline, it is false for those.
  • description and rawDescription, help and rawHelp, and errors are handed to the template only where it draws them. Render them when they are present rather than deciding again from displayLabel or the uiSchema.
  • hideLabel tells a widget that the template, or a field above rendering the same id, draws its label. A widget that keyed its own description on hideLabel should take it from getWidgetDescription() instead, and one that draws its own label should ask widgetDrawsLabel(), which falls back to hideLabel only for a widget rendered without a plan.
  • required still says whether the field is required; a template drawing the label should mark it from presentation.label.requiredMarker, as @rjsf/core's does, which leaves the * off a checkbox, or a widget its theme declares uncheckedIsFalse, whose schema doesn't demand true, unless ui:required: true asks for it while it holds no value (#5388), or an array rendered as one widget, such as a group of checkboxes or a multiple select, holding [] while its schema has no minItems of at least 1 (#5573, #5744).
  • A template drawing the label should give it the for that templateLabelFor() gives, as @rjsf/core's does, which is none where presentation.control.attributes['aria-labelledby'] is set: that control, such as the wrapper of a group of radios or checkboxes, is one a for can't name, and it is named through the label's presentation.label.id instead.

What renders differently:

  • anyOf/oneOf. The field's template labels the field and draws its description, help and errors, whichever option is selected, so the option's control no longer draws a second label (#5358) and the errors no longer disappear with a null option. An option's own title and description render beneath the field's only where they say something else, the description under optionDescriptionId(). An option given no uiSchema of its own no longer takes the field's ui:title and ui:description in place of its own. An option with no title of its own takes the field's label rather than its property name: as its control's label in a theme whose selector draws the field's label itself, as @rjsf/mui's, @rjsf/chakra-ui's, @rjsf/fluentui-rc's and @rjsf/mantine's do, and as its heading while an optional data control keeps the selector off screen, with the field's description beneath that heading. An object with no properties of its own beside its options is labelled as the field rendering those options is; one with properties keeps drawing its heading, under the XxxOf ids it renders beside the options with. As in v6, the option selector isn't required, even for a required field, since it picks an option index rather than a value and holds one unless its widget offers an empty choice (#5527). No built-in selector does, except @rjsf/mantine's where the field's ui:options set allowDeselect or clearable, and clearing the choice clears the field's data with it. A required field shows one *, on the field's own label, whichever option is selected and whether its template or its selector draws that label (#5573). The selected option's own label or heading stays unmarked, and its control stays required. So ObjectFieldTemplate's and ArrayFieldTemplate's required now says whether the heading they draw is marked, which a template using it for anything else should read from the schema instead, and a custom widget of @rjsf/mui, @rjsf/chakra-ui, @rjsf/fluentui-rc or @rjsf/mantine that hands required to a component drawing its label should mark that label from templateMarksRequired(), or it shows a second * beneath an anyOf/oneOf selector and none as that selector. Also as in v6, a discriminated cell a LayoutGridField lays out is required with its cell, since its selector is the only control for the discriminator it writes into the data, where an anyOf/oneOf field's selected option renders the discriminator as a control of its own.

  • Custom fields. A field given as ui:field or a $id draws its own label and description, as one given as ui:field already did: one chosen by a $id used to get the template's label. Declare the parts the template should draw for it under its registry key. A widget it renders with its props, plan included, draws none of the label, description and errors it draws itself (#5591), so one that left its label, description or errors to a widget such as CheckboxWidget declares WIDGET_FIELD_PRESENTATION under its key, or draws them itself. A custom field is handed its own plan as presentation, so one that spreads its props into a field it renders itself, such as StringField, should leave presentation out of them, or that field takes the custom field's label and ids for its own. One that renders SchemaField under its own id, as a wrapper adding markup around the field does, leaves the label and description it was planned to draw to the field it renders, which draws them, while its template draws the help and errors once. A template label declared through fieldDefault would also reach objects and arrays, which have no control for the template's label to name, so they would draw no heading:

    <Form presentation={{ fields: { MyField: { label: 'template', description: 'template' } } }} />
  • ui:label: false hides the description along with the label in every theme, as it already did outside @rjsf/antd.

  • An empty ui:title or ui:description on an object no longer hides its heading or description: the schema's title or description shows instead, as it already did for an array and for a field whose template draws its label. Set ui:label: false to hide both.

  • null fields, and a field rendered through UnsupportedFieldTemplate, draw their own title and description, through TitleFieldTemplate and DescriptionFieldTemplate, rather than a label naming no control.

  • The fallback UI labels its value from outside only where the value is a single control; an object, an array or a checkbox value draws its own heading or label, with the field's description beneath it, as before.

  • A required group of checkboxes shows a * and tells a screen reader it is required only where its schema demands a checked box, with a minItems of at least 1, or where ui:required: true asks for it (#5573). required alone leaves an empty array valid, as the form submitted [] with nothing checked, so such a group shows no *, and its widget's required is false: add minItems: 1 to keep them, which also has the form demand a box. The group's accessible name then ends in "required" (TranslatableString.RequiredGroupCue), so a test finding it by name, such as getByRole('group', { name: 'Colors' }), should expect Colors required. A custom checkboxes widget calling getGroupAttributes() passes the registry and renders the requiredCue it returns, or its name stays as it was, as does one calling @rjsf/daisyui's getGroupAndOptionProps(), which takes the registry and returns the requiredCue alike, while getGroupProps() returns no cue. Only minItems and ui:required are read: a required group with no minItems shows no * or cue though a contains rejects [], so the form blocks that submit; give such a group minItems: 1 or ui:required: true. A group holding no value, as emptyObjectFields: 'skipEmptyDefaults' leaves it, keeps its * and cue, since the form rejects that. An anyOf/oneOf field follows the option shown: a required field shows no * while that option holds the [] of such a group or the false of a checkbox whose schema accepts it.

  • A required multiple select, file input or custom array widget is required, by its * and the required its widget is handed, only where its schema has a minItems of at least 1 or ui:required: true asks for it, as a group of checkboxes is (#5744). getDefaultFormState() seeds a required array with [], which required alone accepts, and a <select multiple>'s native required had the browser block that submit, so the form called neither onSubmit nor onError. A form that relied on that to force a choice adds minItems: 1, which also has the form report an empty selection, and a custom array widget reads its required prop rather than assuming one. As for a group, one holding no value, as emptyObjectFields: 'skipEmptyDefaults' leaves it, stays required, since the form rejects that. An array rendered as a list of items keeps its *.

  • Labels a widget draws take the field's label as the template's does: an array item whose items schema has a title is labelled by it rather than by the numbered title (Tags-1), and a deprecated field's carries the DeprecatedLabel marker.

  • Headings. @rjsf/core's TitleFieldTemplate puts the titleId() it is handed on a <span> around the title's text rather than on its <legend>, since an object's option selector is named by that id and would otherwise take the * and the optional data control's button into its name. A selector or style aimed at legend#root__title should aim at legend #root__title, and a custom TitleFieldTemplate should put its id on the title's text alone.

  • Group labels. @rjsf/core's FieldTemplate draws the label of a RadioWidget or CheckboxesWidget with no for, which named the group's wrapper <div>, an element a for can't label; the group is named by the label's fieldLabelId() through aria-labelledby. A selector aimed at label[for="root_color"] for such a field should aim at #root_color__label, which is on a <span> around the label's text, as a heading's id is, so a group named by it takes no *; label:has(#root_color__label) matches the label itself. RatingWidget renders its stars as a radiogroup on the wrapper <div>, named the same way, where its label's for named its hidden <input>; the wrapper carries the field's id in that <input>'s place, so a selector aimed at input#root_rating should aim at #root_rating. AltDateWidget, and so AltDateTimeWidget, wraps its list of date parts in a group <div> carrying the field's id, named the same way, around the <ul>, which keeps its list role, where its label's for named an id no date part carried, so a selector aimed at label[for="root_when"] should aim at #root_when__label. An anyOf/oneOf field has its label drawn with no for too while the option it shows renders no control at the field's id for a for to name, such as an object, an array of items or a null, renders a group such as AltDateWidget's, or renders options of its own, whose option shown may be an object, which is named by the label through aria-labelledby, as the option selector is whatever option is shown, so a selector aimed at label[for="root_pet"] for such a field should aim at #root_pet__label; while it shows an option rendering a single control, such as a string's input or an array's multi-select or file input, the label keeps the for. While it shows no option, as after its selector's empty choice is picked, the label has no for whatever its options are, since nothing renders at the field's id. Where AnyOfField or OneOfField is declared labelledBy: 'aria-labelledby', as presentation={{ fields: { OneOfField: { labelledBy: 'aria-labelledby' } } }} declares it, the label has no for whatever option shows, and a single control is named by it through aria-labelledby. An option given a ui:field counts as rendering a single control, so one wrapping an object keeps the for; to draw none, register the field under a key and declare labelledBy: 'aria-labelledby' for that key. A widget of your own registered as RadioWidget, CheckboxesWidget, RatingWidget, AltDateWidget or AltDateTimeWidget takes that declaration with the key, so it is named only through the aria-labelledby it spreads from getControlAttributes() or getGroupAttributes(); one that renders a single control under its id and spreads neither should be declared labelledBy: 'for' under its key, as presentation={{ widgets: { CheckboxesWidget: { labelledBy: 'for' } } }} declares one registered as CheckboxesWidget, to keep the label's for. One that draws its own label, such as a date picker registered as AltDateWidget, should be declared label: 'self' under its key, so the template draws no second label. @rjsf/antd declares its RangeWidget labelledBy: 'aria-labelledby', since a for reaches only the slider's root rather than its handle, so a widget of your own registered as RangeWidget under antd's theme takes that declaration too. A wrapper of antd's slider registered under a key of your own, or the slider itself registered under one while RangeWidget holds another widget, takes no declaration, so declare that key labelledBy: 'aria-labelledby' for its handle to be named; one passed to ui:widget as a component has no key to declare, so register it under one. Under antd, #root_color__label is likewise on a <span> around the label's text, which also carries the label's title, so label:has(#root_color__label) matches the label.

  • An untitled field has no label, so a required array item with no title no longer renders a label holding only the required marker; the input is still required.

  • additionalProperties. The key input is labelled wherever the entry's value is, whoever draws that label, so a checkbox entry's key is now labelled too, and where ui:label: false hides that label, the key input is named by it through aria-label. An object or array entry's heading shows its ui:title, or its schema title, where it has one, as before.

  • Layout grid anyOf/oneOf cells. A discriminated union a LayoutGridField lays out draws its label whatever its schema's type, as one with no type already did: one declaring type: 'object' beside its oneOf drew none, and left its selector unnamed. Set ui:label: false on the cell to hide it; the selector is still named by its text.

  • Accessibility. A control's aria-describedby names only elements that render, an AltDateWidget's parts included, it is aria-invalid while it has errors, except a radio, whose radiogroup is instead, the template's label carries fieldLabelId() on its text, and a group of radios or checkboxes is labelled by that label through aria-labelledby. A SelectWidget of your own describes an AltDateWidget's parts only by reading its props through widgetAriaProps(): DateElement hands it a plan of its own rather than an aria-describedby.

  • @rjsf/mantine's errors and RatingWidget. Errors its widgets don't show, those of a custom widget or ui:field, @rjsf/core's RatingWidget or a widget whose inputWrapperOrder leaves out 'error', are shown by FieldErrorTemplate, where they were announced from a hidden copy and never shown; see the Mantine ids section. A custom ui:field that hands one of this theme's widgets rawErrors but not its plan, as FieldProps.presentation advises for a field that doesn't declare WIDGET_FIELD_PRESENTATION, shows the field's errors twice: the widget, given no plan, draws them, and so does the template. Pass the widget hideError or no rawErrors, or declare WIDGET_FIELD_PRESENTATION for the field and hand the widget its presentation. The theme declares @rjsf/core's RatingWidget label: 'template' and description: 'template', since it draws neither, so a widget of your own registered as RatingWidget that draws its own label should be declared label: 'self' under that key, as the "Group labels" entry above says, and one that draws its own description description: 'self', or it shows them twice, or, where it draws its label only while widgetDrawsLabel() is true and spreads no aria-labelledby from getControlAttributes() or getGroupAttributes(), its control is left with no name.

  • @rjsf/chakra-ui's ids, RatingWidget and alt-date widgets. The element a user focuses carries the field's id rather than an id Ark generated: the text input, the textarea, the number input's and the checkbox's <input>, the select's trigger and the slider's thumb, and in a radio or checkboxes group, which carries the field's id itself, each <input> its optionId(). A selector aimed at an Ark id, such as select:root_color:trigger or checkbox:root_color-0:input, should aim at #root_color or the option's id. TitleField puts the title id on a <span> around the title's text inside its <h5> rather than on the box around the heading, its optional data control and its separator, so a selector or style aimed at #root__title now reaches that <span>. The select's label has no for, so a selector aimed at label[for="root_color"] should aim at #root_color__label, and a checkboxes group's label is drawn as a <div> styled as the fieldset's legend rather than as a <legend>, so a legend selector no longer reaches it. The theme declares @rjsf/core's RatingWidget and its own AltDateWidget and AltDateTimeWidget label: 'template', since none of them draws a label, so a widget of your own registered under one of those keys that draws its own label should be declared label: 'self' under its key, as the "Group labels" entry above says, or it shows two labels, or, where it draws its label only while widgetDrawsLabel() is true and spreads no aria-labelledby from getControlAttributes() or getGroupAttributes(), its control is left with no name.

  • @rjsf/daisyui's headings and triggers. TitleField puts the title id on a <span> around the title's text inside its <h2>, and ArrayFieldTitleTemplate on one inside its <h3>, each followed by a * for a required object or array, so a selector or style aimed at div#root__title or h3#root_tags__title should aim at #root__title or #root_tags__title. A radio, checkboxes, rating or alt-date field's label has no for, as @rjsf/core's doesn't, so a selector aimed at label[for="root_color"] should aim at label:has(#root_color__label). FieldTemplate's label puts its label id on a <span> around the label's text, for every field, as @rjsf/core's does, so a selector aimed at label#root_name__label should aim at label #root_name__label, which matches that <span>, or at label:has(#root_name__label) for the label together with its *. SelectWidget's trigger, like the date pickers', has the combobox role, so a query for it by the button role should use combobox. Both take the keys that role promises: Down Arrow, or Alt+Down Arrow, opens either, a date picker's moving focus onto its selected day, else today, and in SelectWidget Down and Up Arrow move into the listbox and between its options, Home and End to its first and last, from the trigger too, and Enter or Space on an option of a single select, or a click on one, chooses it and closes the listbox, as Escape does, returning focus to the trigger. The trigger is SelectWidget's one tab stop, so Tab moves on to the next field rather than through the options, and Shift+Tab from an option closes the listbox as it returns focus to the trigger. The listbox opens on daisyUI's :focus-within, so it stays open while focus is anywhere inside it, an option included, and closes once focus leaves it.

  • @rjsf/fluentui-rc's groups, required and headings. RadioWidget and CheckboxesWidget draw their label with no for, which named the radio group's wrapper or, for CheckboxesWidget, an id no element had, and name the group by it through aria-labelledby, so a selector aimed at label[for="root_color"] should aim at #root_color__label. FieldTemplate's Field no longer takes required, which Fluent handed every control inside it, so only the controls the plan marks are required, and a custom widget whose Fluent control took required from the Field sets it itself. TitleField puts the title id on a <span> inside its <h5>, so a selector aimed at div#root__title should aim at #root__title. SelectWidget draws no label holding only a required field's *, and a dropdown no label names carries an aria-label: the field's label, its placeholder or the translated prompt, so a snapshot of an untitled select gains it. The theme declares @rjsf/core's RatingWidget, AltDateWidget and AltDateTimeWidget label: 'template', since none of them draws a label, so a widget of your own registered under one of those keys that draws its own label should be declared label: 'self' under its key, as the "Group labels" entry above says, or it shows two labels, or, where it draws its label only while widgetDrawsLabel() is true and spreads no aria-labelledby from getControlAttributes() or getGroupAttributes(), its control is left with no name.

  • @rjsf/mui's slider, groups, headings and errors. RangeWidget puts the field's id on its slider's <input> rather than on the slider's root, so a selector aimed at span#root_x should aim at input#root_x, except for a slider of several values, which keeps the id on its root. A radio or checkboxes field's label has no for, which named the group's wrapper, and names the group through aria-labelledby, so a selector aimed at label[for="root_color"] should aim at #root_color__label. TitleField puts the title id on a <span> around the title's text inside its heading, so a selector or style aimed at div#root__title should aim at #root__title. FieldErrorTemplate's messages no longer carry ids of their own, so a selector aimed at #root_x__error-0 should aim at the items of #root_x__error. The theme declares @rjsf/core's RatingWidget, AltDateWidget and AltDateTimeWidget label: 'template', since none of them draws a label, so a widget of your own registered under one of those keys that draws its own label, such as a MUI Rating registered as RatingWidget or a date picker registered as AltDateWidget, should be declared label: 'self' under its key, as the "Group labels" entry above says, or it shows two labels, or, where it draws its label only while widgetDrawsLabel() is true and spreads no aria-labelledby from getControlAttributes() or getGroupAttributes(), its control is left with no name.

  • @rjsf/react-bootstrap's groups, headings, help and descriptions. RadioWidget's and CheckboxesWidget's Form.Group carries the field's id and the radiogroup or group role, named by the template's label, which FieldTemplate draws with no for, as it does for a rating or alt-date field, so a selector aimed at label[for="root_color"] should aim at label:has(#root_color__label). FieldTemplate's label puts its plan's label id on a <span> around the label's text, as core's does, so a selector aimed at label#root_color__label should aim at label #root_color__label, which matches that <span>, or at label:has(#root_color__label), which matches the label with its *. TitleField puts the title id on a <span> around the title's text, followed by a * for a required object or array, so a selector aimed at div#root__title should aim at span#root__title. TitleField draws the title it is handed rather than ui:title over it, and ArrayFieldTemplate draws only the heading ArrayField hands it as title, as core's do, so a custom field rendering either with a title of its own sees that title drawn, where the field's ui:title replaced it. FieldHelpTemplate and FieldTemplate's description render their Form.Text as a <div>, so a selector or style aimed at small#root__help should aim at div#root__help.

  • @rjsf/shadcn's range, groups, headings, help and descriptions. RangeWidget puts the field's id on its slider's thumb rather than its root, and the label names the thumb with no for. An empty range draws its thumb at its minimum, so a screen reader announces that minimum as its value, though the form holds no value until the thumb is pressed or moved. RadioWidget's and CheckboxesWidget's wrapper carries the field's id and the radiogroup or group role, named by the template's label, which FieldTemplate draws with no for, so a selector aimed at label[for="root_color"] should aim at label:has(#root_color__label). FieldTemplate's label puts its plan's label id on a <span> around the label's text, for every field, as core's does, so a selector aimed at label#root_name__label should aim at label #root_name__label, which matches that <span>, or at label:has(#root_name__label), which matches the label with its *. TitleField puts the title id on a <span> around the title's text, so a selector aimed at div#root__title should aim at span#root__title. FieldHelpTemplate and FieldTemplate's description render in a <div> rather than a <span>, so a selector or style aimed at span#root__help should aim at div#root__help.

Breaking changes: schema resolution​

@rjsf/utils now parses each root schema once into a graph of its subschemas, and retrieveSchema(), getDefaultFormState() and the form all stop a recursive $ref by one rule. A $ref is compared by the subschema it reaches, so two spellings of one target are one $ref. A field stops, with an Expand Cycle button in the form, where the first $ref it enters is already on the way down to it, or where any $ref it enters already occurs twice on that way. Every schema that overflowed the stack or ran out of memory on earlier releases now terminates. The sections below list what changes, each with the results before and after. "Before" is what 6.11.0, the last release, gives.

retrieveSchema() resolves one level at a time BREAKING CHANGE​

retrieveSchema() merges the schema's own $ref, allOf, if/then/else, dependencies and the patterns its properties match, as before. A $ref nested under properties, items or an anyOf/oneOf option is no longer resolved in place. It is handed out as written:

const rootSchema = {
definitions: { address: { type: 'object', properties: { city: { type: 'string' } } } },
type: 'object',
properties: { home: { $ref: '#/definitions/address' } },
};
const retrieved = retrieveSchema(context, rootSchema, rootSchema);
// Before: retrieved.properties.home is the address definition, with its `properties`
// After: retrieved.properties.home is `{ $ref: '#/definitions/address' }`
const home = retrieveSchema(context, retrieved.properties.home, rootSchema);
// home is the address definition, with its `properties`

SchemaField and the other fields already retrieve each subschema they render, so a form renders the same. If your code reads a nested subschema out of one retrieveSchema() result, retrieve that subschema again, as the example does.

Every subschema retrieveSchema() hands out carries a Symbol-keyed property, RJSF_PROVENANCE_KEY, recording where it came from. A spread copy keeps it. It is enumerable, so toEqual() sees it: a test comparing a retrieved schema with one written by hand deletes RJSF_PROVENANCE_KEY from a copy first, or registers an equality tester that does. Retrieving such a subschema resolves it as the field it is. A recursive $ref is therefore flagged with RJSF_REF_CYCLE_KEY at the same place however many times a schema is retrieved, and retrieving a schema that retrieveSchema() returned gives it back unchanged:

const once = retrieveSchema(context, rootSchema, rootSchema, formData);
retrieveSchema(context, once, rootSchema, formData);
// Before: one more level of every recursive `$ref` is unrolled, still flagged as a cycle
// After: equal to `once`

A schema without that Symbol, such as one built in code, is resolved from the top, as before.

Expand Cycle reveals one level per click BREAKING CHANGE​

expandRefCycle() returns the schema of a flagged field resolved one level further, which CyclicSchemaField renders when its Expand Cycle button is clicked (see Option selection and cycle expansion are form state). One click now reveals one more level of the recursion, whatever the recursion goes through:

  • { type: 'object', properties: { child: { $ref: '#' } } }, after clicking #root_child_child__expand. Before: the next button is #root_child_child_child_child__expand. After: it is #root_child_child_child__expand.
  • pl = { type: 'object', properties: { a: { type: 'string' }, child: { $ref: pl } } } with a root $ref to pl, after clicking #root_child__expand. Before: the next button is #root_child_child_child__expand. After: it is #root_child_child__expand.
  • A recursion through a then or an allOf overflowed the stack or ran out of memory (below). It now reveals one level per click as well.

A few recursions now stop at a different place:

  • The first row of an array under a root $ref stops a recursive property where every other row does. With node = { properties: { name, kids: { type: 'array', items: { $ref: node } }, best: { $ref: node } } }, a root $ref to node and the data { kids: [{}] }: before, root_kids_0_best rendered and root_kids_0_best_best was collapsed. After, root_kids_0_best is collapsed, as it already was for deeper rows and under a root that is not a $ref.
  • A $ref spelled #/definitions/%41 is the same target as #/definitions/A. With A = { properties: { name, child: { $ref: '#/definitions/%41' } } } and a root $ref to A: before, root_child_child was collapsed. After, root_child is collapsed, as it is when child is written { $ref: '#/definitions/A' }.
  • A recursion through a chain of aliases stops at the first alias, since each alias is compared by the definition it reaches. With node = { type: 'object', properties: { n: { type: 'string' }, c: { $ref: A1 } } }, A1 = { $ref: A2 }, A2 = { $ref: node } and a root $ref to node: before, root_c_n rendered and root_c_c was collapsed. After, root_c is collapsed.
  • Each row of an array that the data holds renders the rows the data has below it, under any root. With node = { properties: { v, kids: { $ref: list } } }, list = { type: 'array', items: { $ref: node } }, a root that is node written out rather than a $ref to it, and the data { v: 'a', kids: [{ v: 'b', kids: [{ v: 'c', kids: [{ v: 'd' }] }] }] }: before, root_kids_0_kids was collapsed, hiding c and d. After, every row renders, down to root_kids_0_kids_0_kids_0_v, as it already did under a root $ref to node.
  • A row added to an array of a recursive definition gets the defaults the form's first row got. With A = { properties: { v, b: { $ref: B } } }, B = { type: 'array', minItems: 1, items: { $ref: A } } and a root $ref to A, after clicking #root_b__add: before, the new row was filled five levels deep, down to root_b_1_b_0_b_0_b_0_b_0_v. After, it is filled as root_b_0 is, down to root_b_1_b_0_b_0_v.

Some forms ran out of memory or overflowed the stack, and they now render with an Expand Cycle button where the recursion comes back round:

  • an allOf or a then around the recursive $ref, as in child: { allOf: [{ $ref: node }] } (#5501), or a then that declares the recursive property. Before: out of memory for the allOf, a stack overflow for the then. After: #root_child__expand.
  • an anyOf option that refers back to the schema offering it, as in node = { anyOf: [{ $ref: node }, { properties: { x } }] }. Before: out of memory. After: the selector alone, and root_x once the second option is selected.
  • definitions that all refer to one another. Four definitions, each with a $ref property to the three others: before, out of memory. After, 16 inputs and 33 Expand Cycle buttons. The size of such a form still grows factorially with the number of definitions.

Markers on resolved schemas BREAKING CHANGE​

  • resolveUiSchema() resolves a field's $ref as retrieveSchema() does, an $anchor included, so a field written { $ref: '#nd' } gets the ui:definitions entries of its definition's oneOf/anyOf options. Before, it logged could not resolve $ref in resolveUiSchema with Could not find a definition for ... and left them out.
  • A schema that merges itself back in no longer leaves a literal $ref on the result. For Pet with then: { $ref: Cat } and Cat = { allOf: [{ $ref: Pet }, ...] }: before, the merged Pet had a $ref key. After, it has none.
  • RJSF_REF_KEY and RJSF_REF_CYCLE_KEY are still added, at the places described above.
  • An extra key of the data whose additionalProperties or patternProperties $ref names a definition with an allOf keeps RJSF_REF_KEY, so it gets that definition's ui:definitions entry, as a property does. With A = { allOf: [{ type: 'object', properties: { a: { type: 'string' } } }] }, additionalProperties: { $ref: A }, the data { k: {} } and 'ui:definitions': { '#/definitions/A': { 'ui:description': 'FROM_A' } }: before, the allOf merge dropped the marker and k showed no FROM_A. After, it does, as properties: { k: { $ref: A } } already did.
  • With the default @rjsf/validator-ajv8, whose Ajv is draft-07, a $ref into a 2019-09 or 2020-12 root resolves from the first validation on. Before, the first isValid() call with such a root returned false and only later calls with the same root validated, so the first getDefaultFormState() call could take the wrong branch. With $schema 2020-12, $defs: { I: { properties: { k: { const: 'x' } } } }, properties: { k: { type: 'string', default: 'x' } }, if: { $ref: '#/$defs/I' }, then: { properties: { t: { type: 'string', default: 't' } } }, else: { properties: { e: { type: 'string', default: 'e' } } } and the data { k: 'x' }, on a fresh validator: before, the defaults were { k: 'x', e: 'e' }. After, they are { k: 'x', t: 't' }. The form rendered root_k and root_t before and after. To validate such a root under its own dialect, pass AjvClass: Ajv2020 (or Ajv2019) to customizeValidator().
  • Under a draft 2019-09 or 2020-12 root, its $schema written with or without a trailing #, a $ref to an $anchor resolves in retrieveSchema(), getDefaultFormState() and the form, as in resolveUiSchema(). Under draft-07 and earlier $anchor is no keyword, so such a $ref still throws. With a 2020-12 root whose $id is https://e.x/root, $defs: { n: { $anchor: 'nodeA', type: 'object', properties: { v: { type: 'string', default: 'v' } } } } and properties: { p: { $ref: '#nodeA' } }: before, the form and getDefaultFormState() threw Could not find a definition for https://e.x/root#nodeA.. After, the form renders root_p_v, and the defaults are { p: { v: 'v' } }.

Keywords beside a $ref BREAKING CHANGE​

A $ref with keywords beside it is read as JSON Schema reads it, under every draft, in the form, the defaults, option scoring and omitExtraData(): as the allOf of those keywords and of the schema the $ref names. Before, the keywords beside a field's own $ref overrode the schema it names keyword by keyword under every draft, and under draft-07 and earlier a $ref reached through another $ref dropped the keywords beside it. With Pet = { type: 'object', properties: { name: { type: 'string', default: 'n' } }, required: ['name'] } and cat: { $ref: Pet, properties: { meow: { type: 'string', default: 'm' } } }:

  • the form renders root_cat_meow beside root_cat_name, where it rendered root_cat_meow alone, and Pet's required applies to cat;
  • the defaults are { cat: { meow: 'm', name: 'n' } }, where they were { cat: { meow: 'm' } }. The keywords beside a $ref are no allOf the schema declares, so they are filled whatever the allOf defaults behavior;
  • omitExtraData(), which the omitExtraData and liveOmit props run, keeps { cat: { name: 'x', meow: 'y' } } of the data { cat: { name: 'x', meow: 'y', z: 1 } }, where it kept { cat: { meow: 'y' } }.

The same holds where another $ref reaches such a $ref, as v: { $ref: M } reaches M = { $ref: O, properties: { b: { type: 'string', default: 'b' } } } with O = { properties: { a: { type: 'string', default: 'a' } } }. Under draft-07 the form renders root_v_b beside root_v_a, where it rendered root_v_a alone, and the defaults are { v: { b: 'b', a: 'a' } }, where they were { v: { a: 'a' } }. With the options [{ type: 'object', properties: { a: { type: 'string' } } }, { $ref: M }] and no selected option, getClosestMatchingOption() picks the second option for { b: 'y' }, where with @rjsf/validator-ajv8 and @rjsf/validator-cfworker it matched no option and returned the selected option, -1 (@rjsf/validator-ata already picked the second); it picks the first for { a: 'y' }, as before. The keywords beside the $ref come first in the merge, so a field renders them first, and where both sides declare an annotation, theirs wins. A customMergeAllOf merges the two as it merges an allOf, in the form, the defaults, option scoring and omitExtraData() alike.

Annotations are read as before: a $ref whose only other keywords are title, description, default, examples, readOnly, writeOnly, deprecated, $comment, $id, $anchor, $defs, definitions and $schema reads as the schema it names, with the annotations beside it over its own. So { $ref: Status, default: 'active' } defaults to 'active' whatever Status declares, and a $ref beside nothing but $defs fills its target's defaults: with $defs: { a: { $defs: { n: { type: 'number', default: 7 } }, $ref: '#/$defs/a/$defs/n' } } and properties: { l: { $ref: '#/$defs/a' } }, the defaults are { l: 7 } where they were {}.

A dependencies entry is qualified by the properties of the schema that declares it, as before, so one in the schema a $ref names is not qualified by the properties beside the $ref, and one beside a $ref with no properties beside it is qualified by any key the data holds.

getDefaultFormState() folds over the schema graph BREAKING CHANGE​

getDefaultFormState() reads the same schema graph as retrieveSchema(). Each field's schema is merged from its $ref, its allOf (under allOf: 'populateDefaults', or where the field has an if), its then/else and its dependencies, as the form renders the field. A recursive $ref stops where the form shows an Expand Cycle button. Its defaults are filled one level further, once, as if that button had been clicked. The dependencies are applied again until the defaults bring no new key that qualifies another one.

It terminates on every recursive schema, including the ones that overflowed the stack: the shapes of #5500, #5452 a and c, and definitions whose dependencies refer to one another. Five definitions that all refer to one another took about 18 s per call, and now take a fraction of a second.

These overflowed the stack, and now give:

  • A recursion behind an allOf under allOf: 'populateDefaults' is filled one level further, as every other recursion is. With outer = { allOf: [{ $ref: inner }, { properties: { extra: { default: 'e' } } }] }, inner = { properties: { name: { default: 'n' }, back: { $ref: outer } } } and properties: { a: { $ref: outer } }: { a: { name: 'n', extra: 'e', back: { name: 'n', extra: 'e' } } }. With tags = { type: 'array', minItems: 1, items: { $ref: tags }, allOf: [{ minItems: 1 }] }, required: ['t'] and properties: { t: { $ref: tags } }: { t: [[undefined]] }.
  • A schema at the root that refers back to itself through a then fills the property it recurses through, as under a property. With Pet = { properties: { petType: { default: 'cat' }, friend: { $ref: Pet } }, if: { properties: { petType: { const: 'cat' } } }, then: { $ref: Cat } }, Cat = { allOf: [{ $ref: Pet }, { properties: { hunts: { default: 'h' } } }] } and a root $ref to Pet: { petType: 'cat', hunts: 'h', friend: { petType: 'cat', hunts: 'h' } }.
  • A property that a patternProperties pattern matches and that refers back to its definition is filled one level further, as a property without the pattern is, and keeps the pattern's defaults where it stops. With node = { type: 'object', properties: { child: { $ref: node } }, patternProperties: { '^c': { type: 'object', properties: { tag: { oneOf: [{ const: 'p' }] } } } } } and a root $ref to node: { child: { tag: 'p', child: { tag: 'p' } } }.
  • Chains through several cycles of definitions stop at a fixed depth. With cycles of two and three definitions, each holding a property k: { allOf: [{ $ref: Leaf }, { $ref: next }] }, under one root allOf and allOf: 'populateDefaults', the defaults stop after a few levels rather than following the least common multiple of the cycle lengths.

These defaults change:

  • An array whose minItems sits in an allOf is filled to the depth the same array with minItems beside its items is. With pl = { properties: { a: { default: 'x' }, child: { $ref: pl } } } and arr: { type: 'array', items: { $ref: pl }, allOf: [{ minItems: 1 }] } under allOf: 'populateDefaults': before { arr: [{ a: 'x', child: { a: 'x', child: { a: 'x' } } }] }, after { arr: [{ a: 'x', child: { a: 'x' } }] }.
  • Extra keys of the data are filled down to where the schema stops recursing. With map = { type: 'object', properties: { name: { default: 'm' } }, additionalProperties: { $ref: map } }, a root $ref to map and the data { k: { j: { i: {} } } }: before { name: 'm', k: { name: 'm', j: { name: 'm', i: { name: 'm' } } } }, after { name: 'm', k: { name: 'm', j: { i: {} } } }. The data below is kept as it is, and the form still renders every level. Keys a patternProperties pattern matches stop the same way: with node = { type: 'object', properties: { n: { default: 'n' } }, patternProperties: { '^c': { $ref: node } } }, a root $ref to node and the data { c1: { c2: { c3: { c4: {} } } } }: before { n: 'n', c1: { n: 'n', c2: { n: 'n', c3: { n: 'n', c4: { n: 'n' } } } } }, after { n: 'n', c1: { n: 'n', c2: { c3: { c4: {} } } } }.
  • The keys a default object names no longer grow on every call. With the same map and default: { k: {} }: the first call gives { name: 'm', k: { name: 'm' } }, as before. Before, each further call on that result added one more level. After, every call gives the same result.
  • A then gives a property without a type the default its branch declares, as the form renders it. With p: { if: {}, then: { $ref: S, default: 'sib' } } and S = { type: 'string', default: 'def' }: before {}, after { p: 'sib' }. With p: { if: {}, then: { $ref: O, required: ['a'] } } and O = { type: 'object', properties: { a, b }, default: { a: 'da', b: 'db' } }: before {}, after { p: { a: 'da', b: 'db' } }.
  • A chain of dependencies is filled in one call. With k0 having a default and each k(i-1) a dependency adding k(i) with a default, nine keys long: before, the first call gave { k0, k1 } and each further call added one key. After, the first call gives k0 to k8.
  • The keywords beside a $ref apply at every level, as they do in the form. With node = { properties: { name: { default: 'n' }, child: { $ref: node, properties: { own: { default: 'o' } } } } } and a root $ref to node: before { name: 'n', child: { name: 'n', child: { own: 'o' } } }, after { name: 'n', child: { own: 'o', name: 'n', child: { own: 'o' } } }.
  • A default beside a $ref overrides the referenced schema's wherever it stands, as it already did on a property, an array's items or the root: { properties: { status: { $ref: Status, default: 'active' } } } with Status = { type: 'string', default: 'draft' } gives { status: 'active' } before and after. With s = { type: 'string', default: 'target' }, node = { properties: { v: { $ref: s, default: 'beside' }, child: { $ref: node } } } and a root $ref to node: before { v: 'beside', child: { v: 'target' } }, after { v: 'beside', child: { v: 'beside' } }. Along a chain of aliases, with alias = { $ref: s, default: 'alias' } and inner: { $ref: alias }: before { inner: 'target' }, after { inner: 'alias' }. computeDefaults() on { $ref: s, default: 'beside' }: before 'target', after 'beside'. In a draft-07 tuple position, arr: { type: 'array', items: [{ $ref: s, default: 'beside' }, { $ref: s }] }: before { arr: ['target', 'target'] }, after { arr: ['beside', 'target'] }, and the form's first input shows beside. In additionalItems, arr: { type: 'array', minItems: 2, items: [{ type: 'string' }], additionalItems: { $ref: s, default: 'beside' } }: before { arr: [undefined, 'target'] }, after { arr: [undefined, 'beside'] }.
  • An anyOf/oneOf option written as a $ref is merged with its field's default as an option written in place is. With O = { type: 'object', properties: { a: { type: 'number' }, b: { type: 'number' } } }, o: { default: { a: 1 }, anyOf: [{ $ref: O }] } and the data { o: { b: 2 } }: before { o: { b: 2 } }, after { o: { a: 1, b: 2 } }, which the option written in place already gave. With S = { type: 'string', default: 'child' } and s: { default: 'parent', anyOf: [{ $ref: S }] } under nestedDefaultsPrecedence: 'ancestorWins': before { s: 'parent' }, after { s: 'child' }, as for S written in place. An option { $ref: s, default: 'beside' } gives 'beside' where it gave 'target'.
  • An allOf behind the $ref of an extra key of the data follows the allOf defaults behavior, as one behind a property's $ref does. With A = { allOf: [{ type: 'object', properties: { a: { type: 'string', default: 'a' } } }] }, { type: 'object', additionalProperties: { $ref: A } } and the data { k: {} }, under the default allOf: 'skipDefaults': before { k: { a: 'a' } }, and a showed in the form, after { k: {} }. A key a patternProperties pattern matches changes the same way. The keywords beside a $ref are no such allOf: under 2019-09 and 2020-12, with M = { $ref: O, properties: { b: { default: 'b' } } } used as additionalProperties: { $ref: M }, the defaults are { k: { b: 'b', a: 'a' } } before and after. Before, only an additionalProperties of the root schema was filled: under a property, p: { type: 'object', additionalProperties: { $ref: A } } with { p: { k: {} } } gave { p: { k: {} } } before and after. Set allOf: 'populateDefaults' to fill it: { k: { a: 'a' } } before and after.
  • Under allOf: 'populateDefaults', a dependencies entry inside an allOf is filled when a default qualifies it, as one beside properties is. With p: { allOf: [{ dependencies: { a: { properties: { c: { type: 'string', default: 'c' } } } } }, { type: 'object', properties: { a: { type: 'string', default: 'x' } } }] } and no data: before { p: { a: 'x' } }, after { p: { a: 'x', c: 'c' } }, and the form's c input shows c. The same allOf at the root already gave c.
  • A $ref spelled #/definitions/%41 is the same recursion as #/definitions/A. With the A above: before { name: 'n', child: { name: 'n', child: { name: 'n' } } }, after { name: 'n', child: { name: 'n' } }. A chain of aliases stops at its first alias the same way: with node = { properties: { n: { default: 'n' }, c: { $ref: A1 } } }, A1 = { $ref: A2 }, A2 = { $ref: node } and a root $ref to node: before { n: 'n', c: { n: 'n', c: { n: 'n' } } }, after { n: 'n', c: { n: 'n' } }.
  • Without a rootSchema, the schema is its own root whatever the data. A $ref resolved against the schema only while the form data was empty. With { definitions: { a: { type: 'string', default: 'x' } }, properties: { p: { $ref: '#/definitions/a' }, q: { type: 'string' } } }: before, every call threw Could not find a definition for #/definitions/a.. After, it gives { p: 'x' }, and { p: 'x', q: '1' } for the data { q: '1' }. A $ref in a dependencies entry that the data qualifies resolves too, where it threw.
  • A definition that merges a schema which merges it back in, taken as the root schema with no data, gives {} where it gave undefined. With Pet = { type: 'object', properties: { petType: { type: 'string' } }, if: { properties: { petType: { const: 'cat' } } }, then: { $ref: Cat } }, Cat = { allOf: [{ $ref: Pet }, { type: 'object', properties: { hunts: { type: 'boolean' } } }] } and a root $ref to Cat: before undefined, after {}.
  • Under allOf: 'populateDefaults', the option an allOf entry chooses gives its defaults over an earlier entry's, nested as at the root. With o: { type: 'object', allOf: [{ properties: { c: { type: 'string', default: 'm0' } } }, { oneOf: [{ properties: { c: { type: 'string', default: 'opt' } } }, { properties: { z: { type: 'number' } } }] }] }: before { o: { c: 'm0' } }, after { o: { c: 'opt' } }. The same object at the root already gave { c: 'opt' }, and the form renders c from the chosen option. A field's schemas are merged in a fixed order (its own, its $ref, its allOf, the then/else, dependencies, patterns, then the option), and a later one's default wins, as a matching then's already does. Where the option declares no default for the key, the earlier entry's still applies. In such shapes the option's keys can also come first in the defaults object; the values are the same.
  • Under allOf: 'skipDefaults', a deep customMergeAllOf no longer fills a property's allOf defaults. Each field's defaults are computed from the field as the schema declares it, so a merger that also flattens the allOf of the properties inside the allOf it merges, such as createDeepAllOfMerge from @x0k/json-schema-merge, no longer exempts those properties from skipDefaults. With animalInfo: { properties: { animal: { default: 'Cat' } }, allOf: [{ if: { properties: { animal: { const: 'Cat' } } }, then: { properties: { food: { default: 'meat' } } } }] } and an allOf beside it at the root: before { animalInfo: { animal: 'Cat', food: 'meat' } }, after { animalInfo: { animal: 'Cat' } }, as without that merger. allOf: 'populateDefaults' fills it.
  • A root dependencies entry counts a default as its property's value whatever else the form data holds, as a nested one already did. With properties: { animal: { default: 'Cat' }, other: { type: 'string' } } and dependencies: { animal: { properties: { food: { default: 'meat' } } } }, for the form data { other: 'x' }: before { animal: 'Cat', other: 'x' }, after { animal: 'Cat', other: 'x', food: 'meat' }.

These stay as they were:

  • A recursion through an anyOf/oneOf option stops where the same recursion written as a property does, however deep the data goes below it, also when the property reaches the option list through a $ref of its own. With node = { properties: { name: { default: 'n' }, c: { anyOf: [{ $ref: node }, { type: 'null' }] } } }, a root $ref to node and the data { c: { c: { c: {} } } }: { name: 'n', c: { name: 'n', c: { c: {} } } }.
  • getDefaultFormState(pl) fills three levels, and getDefaultFormState({ $ref: pl }) two.
  • A default that satisfies an if, or a choice of anyOf/oneOf option, can switch the branch on the next call, so calling getDefaultFormState() on its own result can add more defaults. With { properties: { a: { default: 'x' } }, if: { required: ['a'] }, then: { properties: { b: { default: 'b' } } } }: the first call gives { a: 'x' }, and the second { a: 'x', b: 'b' }.

The root schema is treated as immutable BREAKING CHANGE​

The graph is parsed once per root schema object and kept while that object lives, and the plans and resolved schemas are kept against it. A change made to the root schema in place is therefore not seen, not even by a new createSchemaUtils() or by Form rendering it again. Before, the next retrieveSchema() read the schema as it then stood. To change a schema, pass a new object, as you would for any other React prop:

const schema = { type: 'object', properties: { color: { type: 'string', enum: ['red'] } } };
createSchemaUtils({ validator }, schema).retrieveSchema(schema);
schema.properties.color.enum = ['red', 'green'];
createSchemaUtils({ validator }, schema).retrieveSchema(schema).properties.color.enum;
// Before: ['red', 'green']
// After: ['red'], since `schema` is the object already parsed

const changed = { ...schema, properties: { color: { type: 'string', enum: ['red', 'green'] } } };
createSchemaUtils({ validator }, changed).retrieveSchema(changed).properties.color.enum;
// Before and after: ['red', 'green']

A schema you hand retrieveSchema() that the root does not hold, such as one a custom field builds from scratch, is parsed once per content and kept for as long as the root, and so is one you hand any other schema utility that resolves it, such as getDefaultFormState(), isSelect() or getClosestMatchingOption(). Calls handed the same content share its result, so do not change such a schema once handed in either. Build it from the schema rather than from form data: one built from form data keeps an entry for each distinct value until the root changes.

createSchemaUtils() no longer rewrites the root's $refs BREAKING CHANGE​

createSchemaUtils() used to rewrite every $ref of a draft 2020-12 root against the root's $id and keep that copy as its root schema. It now keeps the root schema it is given, and each $ref is resolved once, against the $ids around it, where it is looked up (#5529). getRootSchema(), and with it a form's registry.rootSchema, is the schema you passed:

const schema = {
$schema: 'https://json-schema.org/draft/2020-12/schema',
$id: 'https://example.com/root.json',
$defs: { s: { type: 'string' } },
type: 'object',
properties: { a: { $ref: '#/$defs/s' } },
};
const schemaUtils = createSchemaUtils({ validator }, schema);
schemaUtils.getRootSchema().properties.a;
// Before: { $ref: 'https://example.com/root.json#/$defs/s' }
// After: { $ref: '#/$defs/s' }, since getRootSchema() returns `schema` itself
  • A ui:definitions entry is keyed by the $ref as the schema writes it. With the schema above, 'ui:definitions': { '#/$defs/s': { 'ui:widget': 'textarea' } } renders root_a as a textarea. Before, it rendered a text input, and only an entry keyed https://example.com/root.json#/$defs/s applied. Rename such keys to the $ref as written.
  • A $ref the rewrite left as written, because a validator would resolve the rewritten one again to somewhere else, resolves against the resource it sits in wherever it is looked up. Under $id: 'schemas/root.json', with $defs: { x: { type: 'number' }, child: { $id: 'nested/child.json', $defs: { x: { type: 'string' } }, additionalProperties: { $ref: '#/$defs/x' } } }: before, schemaUtils.retrieveSchema() of the additionalProperties that getRootSchema() holds gave the root's { type: 'number' }. After, it gives the child's { type: 'string' }.
  • makeAllReferencesAbsolute() is removed. A schema it rewrote before it was handed to Form or createSchemaUtils() can be handed over as it is.

Option matching and precompiled validators BREAKING CHANGE​

  • getClosestMatchingOption() and omitExtraData() resolve only an option's own $ref and leave the ones below it to the validator.
  • getClosestMatchingOption() scores a property written as a $ref as the definition it names, as far as the form data reaches, whether or not the $ref is recursive, resolving each $ref it follows in the resource of the declaration holding it. With Node = { type: 'object', properties: { next: { $ref: Node } } }, the options [{ type: 'object', properties: { a: { $ref: Node } } }, { type: 'object', properties: { a: { type: 'object', properties: { next: { type: 'object' }, z: { type: 'string' } } } } }] and the data { a: { next: { next: {} } } }: before, it overflowed the stack. After, it picks the first option.
  • Precompiled validator code must be regenerated; see Validators are handed pointers into the root schema.
  • getDefaultFormState() scores a field's options as the schema declares them, so a parent's customMergeAllOf no longer reaches the option its children choose for their defaults.

Validators are handed pointers into the root schema BREAKING CHANGE​

The form checks a condition or an option by handing the validator a $ref to where it sits in the root schema, rather than a copy of it. The rootSchema passed beside it is what the $ref resolves against, so a subschema means what JSON Schema says it means wherever the form reads it, and the schemas a precompiled validator compiles are named by the schema graph rather than enumerated.

A ValidatorType of your own has to resolve the $ref. isValid(schema, formData, rootSchema) is handed { $ref: '#/<pointer>' } for each if a field's resolution checks, the property an anyOf/oneOf option names as its discriminator, an option scoring has no required to drop from, the option MultiSchemaField keeps after a parent declines a switch, and, inside { type: 'object', properties: { <key>: { $ref } } }, the property a dependency's oneOf option is matched on. A property an option declares more than once where it applies whatever the data, as allOf: [{ $ref: Base }, { properties: { petType: { const: 'cat' } } }] does when Base declares petType too, is handed as an allOf of a $ref to each declaration. With this schema, retrieveSchema() for the data { a: 'x' } makes one call:

const schema: RJSFSchema = {
type: 'object',
properties: { a: { type: 'string' } },
if: { properties: { a: { const: 'x' } }, required: ['a'] },
then: { properties: { extra: { type: 'string' } } },
};
// before: isValid({ properties: { a: { const: 'x' } }, required: ['a'] }, { a: 'x' }, schema)
// after: isValid({ $ref: '#/if' }, { a: 'x' }, schema)

@rjsf/validator-ajv8, @rjsf/validator-ata and @rjsf/validator-cfworker register the root under its $id less any fragment, or under ROOT_SCHEMA_PREFIX when that leaves nothing, as rootSchemaIdOf() returns it, and prefix a local $ref with that id through withRootIdRefPrefix(schema, rootSchema); @rjsf/validator-ajv8 uses the id as its uriResolver normalizes it, http://example.com/a%20b.json for http://example.com/a b.json and http://example.com/a.json for http://example.com:80/a.json, which is what Ajv looks a $ref up by. A validator that ignores rootSchema finds nothing for the $ref, so every condition and option it checks fails.

A root carrying an $id. @rjsf/validator-ajv8 and @rjsf/validator-ata named the root by ROOT_SCHEMA_PREFIX in every local $ref, which a root with an $id is not registered under, so a condition holding such a $ref never held. With { $id: 'http://example.com/root.json', definitions: { I: { properties: { k: { const: 'x' } } } }, properties: { k: { type: 'string', default: 'x' } }, if: { $ref: '#/definitions/I' }, then: { properties: { t: { type: 'string', default: 't' } } }, else: { properties: { e: { type: 'string', default: 'e' } } } }, getDefaultFormState() gives { k: 'x', t: 't' } where it gave { k: 'x', e: 'e' }. An $id ending in an empty fragment, as http://example.com/root.json# and the $ids of the draft-06 and draft-07 meta-schemas do, names the same root, so rootSchemaIdOf() leaves that # out of the prefix rather than writing http://example.com/root.json##/if. @rjsf/validator-ajv8 registers such a root under its $id without the # too, which is how Ajv keys it, so a root edited under the same $id replaces the one before it: with a customizeValidator() instance that has validated against { $id: 'http://example.com/root.json#', type: 'object', properties: { k: { type: 'string', default: 'x' } }, if: { properties: { k: { const: 'x' } } }, then: { properties: { t: { type: 'string', default: 't' } } }, else: { properties: { e: { type: 'string', default: 'e' } } } }, the same root with a title added gives the defaults { k: 'x', t: 't' } where it gave { k: 'x', e: 'e' } and logged schema with key or id "http://example.com/root.json" already exists.

@rjsf/validator-cfworker registered a root carrying an $id under ROOT_SCHEMA_PREFIX in its place, so the $refs of a 2020-12 root with an $id, which resolve against that $id, resolved to nothing, and a condition holding one never held. With { $schema: 'https://json-schema.org/draft/2020-12/schema', $id: 'https://example.com/root.json', $defs: { isB: { properties: { k: { const: 'b' } }, required: ['k'] } }, type: 'object', properties: { k: { type: 'string' } }, if: { $ref: '#/$defs/isB' }, then: { properties: { t: { type: 'string', default: 'THEN' } } }, else: { properties: { e: { type: 'string', default: 'ELSE' } } } } and the data { k: 'b' }, getDefaultFormState() gives { k: 'b', t: 'THEN' } where it gave { k: 'b', e: 'ELSE' }. Options read from that root match the same way: with $defs: { isA: { type: 'object', properties: { k: { const: 'a' } }, required: ['k'] }, isB: { type: 'object', properties: { k: { const: 'b' } }, required: ['k'] } } and oneOf: [{ $ref: '#/$defs/isA' }, { $ref: '#/$defs/isB' }] in such a root, getFirstMatchingOption({ k: 'b' }, schemaUtils.getRootSchema().oneOf) gives 1 where it gave 0.

One validator, several roots. A validator caches what it compiles for a schema, and { $ref: '#/definitions/X' } means something else under every root. isValid({ $ref: '#/definitions/X' }, 's', { definitions: { X: { type: 'number' } } }) after the same call with a root whose X is a string gives false where it gave true: replacing the root drops what was compiled against it.

Option scoring. getFirstMatchingOption() and getClosestMatchingOption() match an option as JSON Schema reads it, and check in JavaScript that the data holds a key the option declares: a key named by its properties or patternProperties, or by those of its $ref target, its allOf entries, its then/else and its dependencies schemas. Where something in that scope declares properties, scoring drops every required in the scope, and every dependencies entry listing names, so a user who has yet to fill in a required key is still matched to the option; before, it dropped only the option's own required, and only when the option itself declared properties. An option whose scope declares no properties, as in the "at least one of" pattern, is matched by its required as before:

const rootSchema: RJSFSchema = {
type: 'object',
properties: { email: { type: 'string' }, phone: { type: 'string' } },
oneOf: [{ title: 'By email', required: ['email'] }, { title: 'By phone', required: ['phone'] }],
};
getFirstMatchingOption(context, { phone: '1' }, rootSchema.oneOf, rootSchema);
// before and after: 1

An option written as a $ref, or composed with allOf, is now matched as one written out in place is:

const rootSchema: RJSFSchema = {
definitions: {
A: { type: 'object', properties: { a: { type: 'number' } } },
B: { type: 'object', properties: { b: { type: 'number' } } },
Base: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
},
};
getFirstMatchingOption(context, { b: 1 }, [{ $ref: '#/definitions/A' }, { $ref: '#/definitions/B' }], rootSchema);
// before: 0, the first option matching any object; after: 1
getFirstMatchingOption(
context,
{ kind: 'x' },
[
{ type: 'object', properties: { kind: { const: 'y' } } },
{ allOf: [{ $ref: '#/definitions/Base' }], properties: { kind: { const: 'x' } } },
],
rootSchema,
);
// before: 0, since Base's `required` failed the second option; after: 1

Where an option's own then overrides a keyword when the form renders it, scoring now holds the data to both, as validating the whole form on submit already did. When transformChange rewrites an option switch, MultiSchemaField checks the data against the option it keeps, read the same way. getOptionMatchingSimpleDiscriminator() and relaxOptionsForScoring() are removed, since option scoring no longer calls them; see Changes to existing utility functions.

Dependency oneOf options. A dependencies schema's oneOf option is applied when the property the dependency names fits what the option declares for it, and that declaration is now found in the same scope: the option's own properties, or those of its $ref target, its allOf entries, its then/else and its dependencies schemas. Before, only its own properties or its $ref target's counted, so an option declaring the property only in an allOf entry never applied:

const schema: RJSFSchema = {
type: 'object',
properties: { k: { type: 'string', enum: ['a', 'b'] } },
dependencies: {
k: {
oneOf: [
{ allOf: [{ properties: { k: { enum: ['a'] } } }], properties: { ka: { type: 'string', default: 'A' } } },
{ allOf: [{ properties: { k: { enum: ['b'] } } }], properties: { kb: { type: 'string', default: 'B' } } },
],
},
},
};
getDefaultFormState(context, { schema, rootSchema: schema, formData: { k: 'b' } });
// before: { k: 'b' }, the form shows `k` alone and warns "ignoring oneOf in dependencies"
// after: { k: 'b', kb: 'B' }, the form shows `k` and `kb`

The same reading can make a second option apply where one did, and the dependency then applies neither, since exactly one option has to match:

const schema: RJSFSchema = {
type: 'object',
properties: { k: { type: 'string', enum: ['a', 'b'] } },
dependencies: {
k: {
oneOf: [
{ allOf: [{ properties: { k: { enum: ['a', 'b'] } } }], properties: { ka: { type: 'string', default: 'A' } } },
{ properties: { k: { enum: ['b'] }, kb: { type: 'string', default: 'B' } } },
],
},
},
};
getDefaultFormState(context, { schema, rootSchema: schema, formData: { k: 'b' } });
// before: { k: 'b', kb: 'B' }, the form shows `k` and `kb`
// after: { k: 'b' }, the form shows `k` alone and warns "ignoring oneOf in dependencies"

Precompiled validators. Regenerate precompiled validator code with compileSchemaValidators() or compileSchemaValidatorsCode(): code generated by an earlier version throws No precompiled validator function was found. The new getValidationSchemas(rootSchema) lists what is compiled: the root, a $ref to each if and option, to the property each option declares under any discriminator.propertyName in the root, the copy of an option scoring derives where it drops a required, each dependency condition, and, where an option is a boolean, {}, the schema SchemaField retrieves it as, with { not: {} }, the schema a false option reads as. schemaParser(), its 6.x name, is gone; call getValidationSchemas() in its place. The list depends on the root alone and grows with its size, so schemas that failed to compile or failed in the form now work:

Schema6.11.07.0.0
definitions.node recursing through u: { anyOf: [{ $ref: node }, { type: 'string' }] }, used as v: { oneOf: [{ $ref: node }, { type: 'number' }] }the compile throws can't resolve reference #/definitions/nodecompiles; v matches { n: 'a', u: { n: 'b' } } to option 0 and 5 to option 1
a root with dependencies: { a: ['b'] } and oneOf: [{ $ref: '#' }, { properties: { c } }] (#5452 c)the compile overflows the stackcompiles
an option with properties: { id } and patternProperties: { '^s_': ..., '^s_n': ... }, data { id: 'a', s_na: 'xx' } (#5454)scoring the retrieved options throws No precompiled validator function was foundoption 0
an option { $id: 'myOption', properties: { id }, additionalProperties: { type: 'string' } }, data { id: 'a', extra: 'x' } (#5455)the compile throws schema with key or id "myOption" already existsoption 0
s: { allOf: [{ discriminator: { propertyName: 'kind' } }], oneOf: [A, B] }, or s: { discriminator, allOf: [{ oneOf: [A, B] }] }, where A and B declare kind as const 'a' and 'b', data { b: 'q' }scoring the options throws No precompiled validator function was foundoption 1

A schema a merge makes has no place in the root and is not compiled, as in 6.11.0: an option a customMergeAllOf rewrites, or one made by merging the oneOfs of two patternProperties matching the same key, throws No precompiled validator function was found when the form scores it. The error names the subschema when the schema it has no function for is a $ref. A subschema the root holds in several places, as a schema module that reuses one object holds it, is compiled at each place, so code compiled from the module serves an equal copy of it, such as its JSON, and the other way round, as in 6.11.0.

@rjsf/validator-ata compiles each schema as a document of its own, and ata-validator resolves a $ref only to an entry of the definitions or $defs in it. So each document carries what it reaches of the root: every subschema of the root it names through a $ref, such as a definition, a property, an option or the root itself, as an entry of its own in one container, the $ref renamed to it, since ata-validator compiles nothing for a document holding both definitions and $defs, and does not percent-decode a $ref such as #/definitions/c%20d. A $ref beside other keywords is compiled as an allOf entry beside them, as 2019-09, 2020-12 and Ajv read it, since ata-validator compiles no $ref beside keywords that assert something. A property that refers back to itself, tree: { type: 'object', properties: { name: { type: 'string' }, kids: { type: 'array', items: { $ref: '#/properties/tree' } } } }, now validates, reporting .tree.kids.0.kids.0.name for { tree: { kids: [{ kids: [{ name: 5 }] }] } }, where 6.11.0 overflowed the stack. A $ref that names nothing in the root, or one into another document, leaves ata-validator nothing to compile, which throws was not compiled to a standalone validator when the form validates against it. ata-validator compiles no function for a schema holding only annotations either, so each schema without a not or a $ref is compiled with not: { not: {} }, which always holds: a precompiled validator for the root { title: 'Anything' } returns true from isValid() where it threw was not compiled to a standalone validator.

JUNK_OPTION_ID is removed from @rjsf/utils: nothing read it any more.

Option selection and cycle expansion are form state​

The anyOf/oneOf option a field shows and which collapsed $ref cycles are expanded are kept by the form, by field path, rather than by the AnyOfField/OneOfField and CyclicSchemaField components. They survive the field unmounting and mounting again and follow the moves the form makes, of array items and of additional properties renamed or removed. The sections below list what changes; "before" is what 6.11.0 gives.

The selected option no longer follows the data while you type BREAKING CHANGE​

With no selection, a field shows the option getClosestMatchingOption() picks for its data, as before. A selection, the user's or that pick once an edit is made inside the option, holds for the data the form last rendered under it and for the data the form's own changes inside the option leave at the field, whatever those edits made of it, so a switch or an edit transformChange refuses keeps it too. Other data, such as setFieldValue()'s, a reset, a transformChange that rewrites a switch to data the chosen option does not fit, or an edit to a property the object declares beside its options that the options can disagree about, one an option names under properties, such as with a const that tells the options apart, or under required, or any such property where an option carries a keyword that reads the object whole, such as minProperties, not or additionalProperties, is shown under the option getClosestMatchingOption() picks for it, the selection winning a tie, as on 6.11.0, even when it is the data the field showed before the switch. An edit to a property every option is silent about, where each carries only type, annotations such as title, and properties and required that leave the property out, holds the option shown, as an edit inside the option does: every option says the same of that property, so the edit cannot be a choice between them. That pick does not replace the selection, and the selection is kept by field path, so it outlives the data it was made for. A field whose data holds a value for the discriminator.propertyName its schema declares is always shown under the option that value names, as before: the discriminator is the selector, so an edit to it is a switch.

  • With o: { anyOf: [{ title: 'A', type: 'object', properties: { a: { type: 'string', default: 'd' } } }, { title: 'B', type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' } } }] }, select B, clear root_o_a and type d. Before: the selector went back to A, whose default the data now matched, and root_o_b disappeared. After: it stays on B, with root_o_b.
  • With { type: 'object', properties: { name: { type: 'string' } }, oneOf: [{ title: 'A', properties: { a: { type: 'string' } } }, { title: 'B', properties: { a: { type: 'string', maxLength: 2 }, b: { type: 'string' } }, required: ['b'] }] }, select B, type z into root_b, abc into root_a and then n into root_name. Before: the selector went to A at abc, which B rejects, and root_b disappeared; with omitExtraData and liveOmit, b: 'z' was dropped there. After: it stays on B through both edits, with { a: 'abc', b: 'z', name: 'n' }.
  • With { type: 'object', properties: { a: { type: 'string' }, b: { type: 'string' } }, oneOf: [{ title: 'A', required: ['a'] }, { title: 'B', required: ['b'] }] } and {}, type x into root_b: the selector goes to B, before and after, since B's required names b. So does anyOf: [{ title: 'Few', maxProperties: 1 }, { title: 'Many', minProperties: 2 }] with { a: 'x' } when y is typed into root_b: Many, before and after.
  • A condition that swaps the option list a field offers starts the choice over. With kind: { enum: ['a', 'b'], default: 'a' }, if: { properties: { kind: { const: 'a' } } }, then: { properties: { x: { anyOf: [A1, A2] } } } and else: { properties: { x: { anyOf: [B1, B2] } } }, select A2, then b for kind. Before: B2, the index carried over. After: B1, the option the data matches; selecting a again shows A2 before and after.
  • A field that mounts again shows the option selected before. With the o above, select B, then render the form with o: { 'ui:field': Nothing, 'ui:fieldReplacesAnyOrOneOf': true } and again without it. Before: A. After: B.
  • Data setFieldValue() sets that every option fits shows the selection, rather than the option an earlier call matched. With the root oneOf: [{ title: 'A', type: 'object', properties: { a: { type: 'string' } } }, { title: 'B', type: 'object', properties: { b: { type: 'string' } } }], select B and type y into root_b; setFieldValue('', { a: 'x' }) then shows A before and after, and setFieldValue('', {}) shows, before, A, and after, B.
  • A selection outlives the array item it was made for. With { type: 'array', items: ...that oneOf } and [{}, {}], select B on the second item; setFieldValue('', [{}]) and later setFieldValue('', [{}, {}]). Before: the new second item shows A. After: B, the selection made at that index, which the new item's data fits. An item a field removes or moves takes its selection with it.

AnyOfField and OneOfField read and write the selection with useOptionSelection(), which @rjsf/core exports, and SchemaField reads it to stub an object's extra keys with the selected option (below). A custom AnyOfField or OneOfField that keeps the selection in its own state shows one option while the object beside it is stubbed for another, so read and write it through the hook instead; the options prop stays:

import { useOptionSelection } from '@rjsf/core';

const { selectedOption, selectOption } = useOptionSelection(registry, fieldPath, options, formData, discriminator);
// on a pick: selectOption(index), then onChange(dataForTheNewOption, fieldPath)

An object's extra keys are stubbed once, with the selected option BREAKING CHANGE​

An object field whose anyOf/oneOf options are rendered beside it stubs the extra keys of its data once, for itself and the selected option together. A key the selected option declares is left to the option, which renders it, and the object takes the option's additionalProperties, unevaluatedProperties and patternProperties beside its own: a key either describes is described by both, and a key either forbids is forbidden. The option's own field no longer stubs keys a second time. Where the selected option offers options of its own, the field it renders beside them takes the extra keys in the object's place, for the object, the option and the option it selects together, since only that field knows which of its options declares a key.

  • additionalProperties: { type: 'object', properties: { host: { type: 'string' }, port: { type: 'number' } } } beside anyOf: [{ patternProperties: { '^x-': { type: 'string' } } }], with the data { db: { host: 'h', port: 1 } } (#5462). Before: db rendered twice, as the object's root_db_host and root_db_port and as a second key row the option stubbed. After: once, with root_db_host and root_db_port.
  • properties: { name: { type: 'string' } } beside oneOf: [{ additionalProperties: { type: 'number' } }], with the data { name: 'n', k: 1 }. Before: root_name rendered twice, the second time as a key row the option stubbed. After: once, with k as a number row and the add button beside it.
  • additionalProperties: true beside oneOf: [{ oneOf: [{ properties: { x: { type: 'string' } }, additionalProperties: { type: 'string' } }] }], with the data { x: 'a', extra: 'e' }. Before: x and extra rendered twice, as the object's key rows and the nested option's, with an add button in each of the two object fields. After: once, in the nested option's field, with one add button.
  • additionalProperties: { type: 'string' } beside oneOf: [{ properties: { a: { type: 'string' } }, additionalProperties: false }], with the data { a: 'v', other: 'o' }. Before: root_a rendered twice, once as a string row of the object, and other as a string row. After: root_a once, the option's, and other gets no field, since the option forbids it.
  • additionalProperties: { type: 'string' } beside anyOf: [{ properties: { count: { type: 'number' } } }, { properties: { count: { type: 'number' }, b: { type: 'string' } } }]: add a property and rename newKey to count (#5461). Before: the data was { count: 'New Value' }, the string the add button wrote for a key the object describes, shown in the option's number field and, on 6.11.0, in a second key row as well. After: the data is { count: 0 }, seeded from the option's declaration, with root_count once. The option is the one SchemaField shows, so where a ui:field with ui:fieldReplacesAnyOrOneOf keeps the options from rendering, the key is an extra key and the rename gives { count: 'New Value' }, before and after.
  • retrieveSchema() takes the selected option as a new last argument, selectedOption. Without it, the extra keys are stubbed for the object alone, a key an option declares included, as on 6.11.0.
  • getRenderedOptionSchema() renders the option of an object without its additionalProperties, patternProperties and unevaluatedProperties.
  • ObjectField renders the schema it is handed as it stands, where it retrieved it again for the data: SchemaField hands it the schema already retrieved, its extra keys stubbed with the selected option. A custom field that renders ObjectField itself with a schema it did not receive from SchemaField, such as a definition looked up in the root schema, retrieves it first, or the properties its $ref, allOf and conditions bring in, and the extra keys of the data, get no field: <ObjectField {...props} schema={registry.schemaUtils.retrieveSchema(schema, formData)} />. One that passes on the schema prop it received is unaffected.

getClosestMatchingOption() picks among the candidates BREAKING CHANGE​

getClosestMatchingOption() returns an option the data matches whenever one does, and some option whenever there are options: a tie goes to the selectedOption only where the data matches it, or where no option matches the data, and to the first candidate otherwise. A property holding an anyOf/oneOf of its own adds the score of the option picked among those, plus one where the data matches it, rather than that option's index.

getClosestMatchingOption(context, rootSchema, undefined, [{ type: 'string' }, { type: 'number' }]);
// Before: -1. After: 0
getClosestMatchingOption(context, rootSchema, undefined, [{ type: 'string' }, { type: 'number' }], 2);
// Before: 2, an index past the options. After: 0

A field opens on such a pick, so a field whose first option is { type: 'null', title: 'None' } and whose data several other options match opens on one of those: with five definitions A to E, each { type: 'object', properties: { child: { oneOf: [{ type: 'null', title: 'None' }, ...a $ref to each of the four others] } } }, the root oneOf: [{ type: 'null', title: 'None' }, ...a $ref to each definition] and the formData { child: { child: { child: null } } }, before, the form opened on None and rendered the root selector alone; after, it opens on A and renders a selector at each of the four levels.

The pick is remembered per option list and data object, so a form whose definitions refer to each other through oneOf options matches each level of its data once (#4990): rendering a form of five such definitions with the formData four levels deep validated 2124 times before, four times as many per level, and 122 times after, 35 more per level. Within one call a remembered pick is reused without looking at the data again, since a validator's isValid() must not change the data it checks (see below). A later call reuses it only while the data still holds what a copy kept with it holds, so data you mutate in place between calls is matched for what it holds now; that comparison costs time in proportion to the data below the option list, once per call. An object other than an array or a plain object, such as a Date, a File or a class instance, can't be copied, so a pick for data holding one is reused only within the call that made it: each call matches that data again, still once per level of it, and sees a change made in place since the last call.

A validator's isValid() must leave the data as it is BREAKING CHANGE​

ValidatorType.isValid() must not change the data it is handed, at any depth, since getClosestMatchingOption() reuses a pick it made for some data during the same call without comparing the data again. @rjsf/validator-ajv8 validates a copy of the data in isValid() under Ajv's useDefaults, coerceTypes and removeAdditional options and with a keyword registered with modifying: true, where Ajv wrote defaults and coerced values into the form's data and deleted properties from it while the form checked a condition or an option; validateFormData() and rawValidation() still validate the data itself. @rjsf/validator-ata already validated a copy, and @rjsf/validator-cfworker doesn't change the data. A ValidatorType of your own whose engine fills in defaults, coerces values or removes properties as it validates has to hand it a copy of the data in isValid().

const validator = customizeValidator({ ajvOptionsOverrides: { useDefaults: true } });
const schema: RJSFSchema = { type: 'object', properties: { flag: { type: 'string', default: 'on' } } };
const formData = {};
validator.isValid(schema, formData, schema);
// Before: formData is { flag: 'on' }. After: formData is {}, and isValid() still returns true

Switching from a null option restores the field's default​

A field that held nothing, as after a null option, and switches to an option whose defaults are an object gets the defaults of the field with that option alone, so a default the field declares comes back with the option's own (#5368). Those defaults hold no key set to undefined, so a default an ancestor declares for the field fills the keys they leave, as the form fills any other. With { type: 'object', oneOf: [{ title: 'Obj', type: 'object', required: ['kind'], properties: { kind: { const: 'obj' }, note: { type: 'string', default: 'from-option' }, extra: { type: 'string' } } }, { title: 'None', type: 'null' }], default: { kind: 'obj', extra: 'from-root' } }, select None, then Obj. Before: { note: 'from-option' }. After: { kind: 'obj', note: 'from-option', extra: 'from-root' }, the data the form mounted with. With the same oneOf as properties.w.properties.p and the default declared on the root as { w: { p: { kind: 'obj', extra: 'from-root' } } }, the round trip gave { w: { p: { note: 'from-option' } } } and gives { w: { p: { kind: 'obj', note: 'from-option', extra: 'from-root' } } }. The form mounts with { w: { p: { kind: 'obj', extra: 'from-root' } } }, before and after: on mount the ancestor's default object stands in for the option's own leaf defaults, while the switch writes the option's defaults and the ancestor's fill the keys they leave. An object property of the option gets the empty object a switch from another object option gives it, as before: with meta: { type: 'object', properties: { a: { type: 'string' } } } in Obj, the switch from None writes meta: {} before and after.

Cycle expansion is form state BREAKING CHANGE​

CyclicSchemaField reads which fields are expanded from the form and renders an expanded one from expandRefCycle(schema, rootSchema), which @rjsf/utils exports. retrieveSchema() no longer reads RJSF_REF_CYCLE_KEY: false on a copy of a flagged schema as an expansion: the copy is resolved like any other copy of the field it was made from, keeping its $ref path, so its recursion collapses where the field's would. A custom field that wrote { ...schema, [RJSF_REF_CYCLE_KEY]: false } to expand a field calls expandRefCycle() instead.

  • With t: { $ref: node } and node = { type: 'object', properties: { name: { type: 'string' }, child: { $ref: node } } }, click #root_t_child__expand, then render the form with t: { 'ui:field': Nothing } and again without it. Before: #root_t_child__expand again. After: root_t_child_name and #root_t_child_child__expand, as before the remount.

Sanitize is a diff of the field trees BREAKING CHANGE​

Form sanitizes its data by comparing the field tree the data before a change builds with the one the data after it builds, field by field, as each is rendered: the option the form selects and the $ref cycles the user expanded. A field that left the tree loses its value, unless the object still holds it as an extra key that constrains nothing, as under additionalProperties: true, and that extra key renders it: a scalar, or an object whose keys the extra key takes as its own, stays and renders as an extra key. An array, whose items such an extra key renders none of, is dropped, and so is a value equal to what the form fills the old field with from data without it, such as its default, its const, an empty object or its ui:initialValue or ui:emptyValue, even one the user chose. A kept value is judged by the extra key alone, so a limit the new branch puts on the object's keys, such as propertyNames or maxProperties, can reject it, which validation reports. For the form to drop the value instead, give that branch additionalProperties: false, with the keys it keeps declared in its own properties. Switching an anyOf/oneOf option through its selector leaves each key the data held for this same diff, which judges it against the old option and the whole form's defaults, so a switch keeps and drops what a branch does. A key both options declare is judged the same way, so its clear stays where the defaults fill it alike under both options, a default the field or an outer anyOf/oneOf declares included (#5824). The object's extra keys are described by the new option's additionalProperties joined to its own, so an option with additionalProperties: false drops the property, and one whose extra keys have a schema of their own, as { type: 'string' }, keeps a value that schema's constraints take, as a branch does. A switch to an option declaring no properties no longer clears the object: it keeps the object's own fields and extra keys, whether or not the object takes extra keys and whether or not such a property stays. The kept key can make the data match the old option again: data loaded through initialFormData, formData or reset() opens on the first option it matches, so with options told apart only by the properties they declare it can open on the old one. Give the options a const property, or a discriminator, for the data to open on the option the user chose. A field whose constraints changed (its type, const, enum or constant options, maxItems, or the default the defaults fill it with, from its own schema or any ancestor's default object as defaultFormStateBehavior reads them; see getFilledDefault()) has its value judged again: a value still holding the old default takes the new one, written in place, and an array holding it, or any value the old default was filled out from, to minItems or through a ui:initialValue below the field, is filled out from the new one in place, as a fresh mount fills it, since the form applies a ui:initialValue only on mount; a value the default plays no part in, such as one the field's own ui:initialValue filled in or one whose every item an items ui:initialValue filled, is kept. Removed too is a property declared readOnly holding a scalar or a select's value other than a new default that replaces an old one, and a value of another type, or one the new const or enum rejects, is replaced or dropped as sanitizeDataForNewSchema() does it. A field the old schema gave no default keeps its value when a default first applies to it, a readOnly one included, since nothing tells saved data apart from a value its ui:initialValue or ui:emptyValue filled in. An object the defaults fill key by key, one whose own type renders as object and whose new default is an object too, rather than a select over whole objects or one with a true property, is judged a key at a time instead, against the object default it holds a key of, since the fill can leave some of that default's keys out, as emptyObjectFields: 'populateRequiredDefaults' leaves out an optional one. A key the new default no longer names keeps its value. An array's items are judged by the schema their position declares: a prefixItems or tuple items entry, and past them items, or additionalItems beside a tuple. An item is of another type only where its position's new type leaves out one the old schema declared there, a number counting as naming integer, and a position the old schema declared true, false or nothing at judges the item by the new schema alone. Where an item reaches a position the new schema closes with false, or the new schema rejects a prefix item, whose removal would move the items after it, the whole array is removed instead, for the defaults to fill, as sanitizeDataForNewSchema() clears it. The array keeps its items where the old schema closed that position with false too, and where both schemas declare a schema for a rejected prefix item's position and constrain it alike, its default aside, so the item already failed the old one. It keeps them too where the item is an unfilled prefix item or is at or past the new maxItems, where the user is editing the array or a field in it, and where the array is an item of another array. A field whose constraints did not change keeps its value, whatever else the change did. Changing defaultFormStateBehavior alone changes no schema, but it can change the default a field is filled with, so every field is judged again: with default: { v: 'x' } on the root and v: { type: 'string', default: 'q' }, a form that filled v with 'q' gives { v: 'x' } once nestedDefaultsPrecedence becomes 'ancestorWins'. "Before" is what 6.11.0 gives.

  • A condition inside the selected option replaces its old default (#5465). With cfgFor = (name) => ({ type: 'object', default: { name }, properties: { name: { type: 'string' } } }), thing: { oneOf: [X, Y] } where X is { type: 'object', properties: { mode: { enum: ['a', 'b'], default: 'a' } }, dependencies: { mode: { oneOf: [{ properties: { mode: { const: 'a' }, cfg: cfgFor('a') } }, { properties: { mode: { const: 'b' }, cfg: cfgFor('b') } }] } } }, and the data { thing: { mode: 'a', cfg: { name: 'a' } } }, select b for mode. Before: cfg: { name: 'a' }. After: cfg: { name: 'b' }.
  • A default declared by a branch that requires the key is replaced too. With cfg declared plainly and allOf: [{ if: { properties: { mode: { const: 'a' } }, required: ['mode', 'cfg'] }, then: { properties: { cfg: cfgFor('a') } } }, ...the same for 'b'], select b for mode with the data { mode: 'a', cfg: { name: 'a' } }. Before: cfg: { name: 'a' }. After: cfg: { name: 'b' }.
  • An array or object write is sanitized like any other edit. With list: { type: 'array', items: { type: 'string' } }, color: { enum: ['r', 'g'] } and allOf: [{ if: { properties: { list: { minItems: 2 } } }, then: { properties: { color: { enum: ['r'] } } } }], add a row to list with the data { color: 'g', list: ['x'] }. Before: color: 'g' until the next edit replaced it. After: color: 'r' as the row is added.
  • A schema change of a form that owns its data is sanitized as it arrives, unless the form is disabled or readonly, whose data no edit sanitizes either. With color: { $ref: '#/definitions/Color' } and Color narrowed from enum: ['a', 'b'] to enum: ['a'], and initialFormData { color: 'b' }. Before: color: 'b', kept until an edit sanitized and, with no conditional in the schema, for good. After: color: 'a' at once. Whether 6.11.0 ever replaced the value turned on a conditional elsewhere in the schema, so the form now applies one rule, the one an edit follows, to the schema change itself.
  • A value setFieldValue('', data) sets is taken as written, and no edit judges it unless the edit changes that field's constraints. With mode: { enum: ['basic', 'advanced'] }, color: { type: 'string' }, note: { type: 'string' } and allOf: [{ if: { properties: { mode: { const: 'advanced' } } }, then: { properties: { color: { enum: ['r', 'c'] } } }, else: { properties: { color: { enum: ['r'] } } } }], set { mode: 'basic', color: 'c' } and type into note: color: 'c' stays, which validation reports. An array setFieldValue() writes whole at its own path pairs each item that holds what an item of the array held with that item, so a reorder set back keeps the constraints its rows had.
  • An extra key holding the old branch's additionalProperties default takes the new one, as a declared property does. With mode: { enum: ['a', 'b'], default: 'a' } and dependencies: { mode: { oneOf: [{ properties: { mode: { const: 'a' } }, additionalProperties: tagFor('a') }, { properties: { mode: { const: 'b' } }, additionalProperties: tagFor('b') }] } }, where tagFor = (tag) => ({ type: 'object', default: { tag }, properties: { tag: { type: 'string' } } }), and the data { mode: 'a', extra: { tag: 'a' } }, select b for mode. Before: the form threw Cannot use 'in' operator to search for 'tag' in a as it rendered. After: extra: { tag: 'b' }.

sanitizeDataForNewSchema() takes newSchema, oldSchema and data alone, as on 6.11.0: the oldData, resolvedOldSchema and oldDataForPairing parameters earlier v7 builds added are removed. An object property whose data holds a value other than an object is sanitized to an empty object, or left out where its schema declares no properties, and one the data doesn't hold is left out (see sanitizeDataForNewSchema() removes the keys it discards), rather than sanitized as an empty object, which filled the new defaults of its own properties and descended into their object properties in turn, without end behind a recursive $ref. The defaults the form computes fill those properties in.

ui:required and omitting extra data read the option on screen​

The ui:required check and omitting extra data read the option a field shows and the cycles the user expanded, through a new optional fieldState argument of getUiRequiredErrorSchema() and omitExtraData(), which Form passes. With thing: { anyOf: [{ properties: { aField } }, { properties: { bField } }] }, ui:required on bField and the data { thing: { aField: 'a' } }, a field showing the second option now blocks the submit for the missing bField, where the check read the first option, which the data matches best. With a recursive node whose child is { $ref: node }, ui:required on child.name blocks the submit once the user expands child, and not before. Omitting keeps the data of the option shown: with thing: { anyOf: [{ properties: { a } }, { properties: { b } }] }, the data { thing: { b: 'v' } } and the first option shown, submitting with omitExtraData drops b, which the option on screen has no field for; it kept b while the option was read from the data. An edit reads the option the field shows while it is made, so live omitting and live validation agree with the screen: with the root oneOf: [{ title: 'A', properties: { a: { type: 'string' } } }, { title: 'B', properties: { a: { type: 'string', maxLength: 2 }, b: { type: 'string' } } }], omitExtraData and liveOmit: 'onChange', select B, type z into root_b and abc into root_a. Before: the selector went to A, which the data now matches, and b was dropped with root_b. After: B stays on screen with root_b, and the data is { a: 'abc', b: 'z' }. With ui:required on b and liveValidate: 'onChange' in place of the omit, the same steps report .b must have required property 'b' under B, where the form showed A and no error. Omitting keeps its two policies, as on 6.11.0: an optional object whose filtered content is all empty is pruned, and an anyOf over empty data applies every branch.

A field is named once per data path BREAKING CHANGE​

A field's own ui:field and the schema's $id name a custom field through one slot, read in that order, so a field named by $id renders the way one named by ui:field does (#5391). A named field is applied once per data path (#5377): a SchemaField rendered for the same data below it, and the selected anyOf/oneOf option, render as though no field were named. "Before" is what 6.11.0 gives. With fields={{ MyField }} and anyOf = { type: 'string', anyOf: [{ minLength: 2 }, { maxLength: 5 }] }:

  • schema: { $id: 'MyField', ...anyOf }. Before: MyField is not rendered; the option selector and the selected option's input render. After: MyField renders beside the option selector, above the selected option's input.
  • schema: { $id: 'MyField', ...anyOf } with uiSchema: { 'ui:fieldReplacesAnyOrOneOf': true }. Before: as above. After: MyField renders alone, in place of the options.
  • schema: anyOf with uiSchema: { 'ui:field': 'MyField' }. Before: MyField renders twice, beside the option selector and again in place of the selected option's input. After: once beside the option selector, and the selected option renders its own input (#5358). The field's own label is still left to MyField, as a ui:field always left it.
  • schema: anyOf with uiSchema: { 'ui:field': 'MyField', anyOf: [{ 'ui:field': 'OptionField' }, {}] } and OptionField registered too. Before: MyField beside the option selector, and OptionField for the selected option. After: MyField beside the option selector, and the selected option's own input: the option renders at the data path MyField was named for, so its own ui:field is not applied, and the console warns once that it is ignored. A ui:field from a ui:definitions entry for the option's $ref is ignored the same way, and the warning names uiSchema.anyOf[0], the entry the definition is copied into. The same holds for an option naming OptionField by $id (anyOf: [{ $id: 'OptionField', minLength: 2 }, { maxLength: 5 }]), which 6.11.0 rendered as MyField a second time, though a $id, an identifier first, is not warned about. Without the ui:field on the union, the option's own field renders, as before.
  • schema: { type: 'object', properties: { val: { type: 'string' } } } with uiSchema: { val: { 'ui:field': 'SchemaField' } }. Before: the page runs out of memory. After: the field renders exactly as though no field were named, whichever of ui:field or a $id names it: one input, root_val, labelled by the field's title, with one description, help and option selector. A field named FallbackField likewise renders as it does for a schema whose type sends it there, labelled as though no field were named.

A custom field that renders registry.fields.SchemaField for its own data gets the default field for the schema, whatever the uiSchema it passes names. That field draws the label, description and help the custom field's plan hands down to it, as the plan section describes, and the extra key's input and remove button and the anyOf/oneOf option selector stay with the field's own SchemaField, so each renders once. To render another custom field for the same data, render that field's component directly; to render a different custom field for an option, name it on the option alone, rather than beside a field named for the union. Each Form starts with no field named, so a Form rendered inside a custom field applies its own root ui:field or $id.

getUiOptions() returns a field's options alone BREAKING CHANGE​

getUiOptions(uiSchema, globalOptions) leaves a required in globalOptions out, and no longer returns the root uiSchema's ui:definitions and ui:globalOptions keys as options (#5527). A form-wide required was never read by the required marker or by validation, which take ui:required from the field's own uiSchema, but it reached the options of a boolean field's widget, of an array field's select, checkboxes or file widget, and of the anyOf/oneOf selector. "Before" is what 6.11.0 gives.

  • schema: { type: 'object', properties: { flag: { type: 'boolean' } } } with uiSchema: { 'ui:globalOptions': { required: true } }: the CheckboxWidget's options.required. Before: true. After: undefined.
  • schema: { type: 'string' } with uiSchema: { 'ui:definitions': {} }: the TextWidget's options.definitions. Before: {}. After: undefined.

A field's own ui:required is still an option. Read the form's definitions and global options from registry.uiSchemaDefinitions and registry.globalUiOptions.

ui:options ignores field and the other keys it doesn't take BREAKING CHANGE​

A field, fieldReplacesAnyOrOneOf, definitions or globalOptions in ui:options is ignored, with a console warning, as the exceptions to the equivalence list them. The warning names the key but not the field, and logs once per key, so search your uiSchemas for them rather than waiting on the console. "Before" is what 6.11.0 gives.

  • uiSchema: { 'ui:options': { field: 'MyField' } }. Before: MyField renders. After: the field for the schema's type renders.
  • schema: anyOf with uiSchema: { 'ui:field': 'MyField', 'ui:options': { fieldReplacesAnyOrOneOf: true } }. Before: MyField renders alone, in place of the options. After: MyField renders beside the option selector.
  • uiSchema: { 'ui:options': { definitions: {} } }: a widget's options.definitions. Before: {}. After: undefined.

Move the keys out of ui:options:

- 'ui:options': { field: 'MyField', fieldReplacesAnyOrOneOf: true },
+ 'ui:field': 'MyField',
+ 'ui:fieldReplacesAnyOrOneOf': true,

A layout grid's children entry is affected too, since its extra properties become the cell's ui:options, so a field there moves to ui:field in the cell's own uiSchema entry.

A custom field named by ui:options.field got the template's label and description, and one named by ui:field gets neither. To keep them, declare them under the field's registry key, registering a field given as a component so that it has one.

A custom field named by ui:field isn't handed the select SchemaField now infers for a boolean with labelled oneOf/anyOf options, so one that renders BooleanField shows the checkbox 6.11.0 showed. Set ui:widget: 'select' to get the select.

ui:definitions and ui:globalOptions belong on the root uiSchema. A custom widget that took an option of one of these names through ui:options needs another name for it, since ui:field names a field and getUiOptions() doesn't return ui:definitions or ui:globalOptions.

A typed uiSchema giving one of these keys in ui:options stops compiling, and so does one handing ui:options what getUiOptions() returned, which can hold a field and a fieldReplacesAnyOrOneOf. Leave those two out:

- 'ui:options': getUiOptions(uiSchema),
+ const { field, fieldReplacesAnyOrOneOf, ...options } = getUiOptions(uiSchema);
+ 'ui:options': options,

A custom field rendering FallbackField without a plan has the value field draw its label BREAKING CHANGE​

Under useFallbackUiForUnsupportedType, a custom field that renders registry.fields.FallbackField without handing it its presentation has the value field, below the type selector, draw the field's label and description, however the form reached the custom field (#5389). Without a plan, FallbackField can't tell whether the custom field draws a label of its own, so it always has the value field draw one, which a custom field drawing none needs to give its value an accessible name. "Before" is what 6.11.0 gives. With schema: { type: 'object', properties: { val: { type: ['string', 'number'], title: 'Val', description: 'Desc' } } } and a custom field MyField that draws no label or description of its own:

  • MyField named by ui:field. Before: the page runs out of stack, since FallbackField rendered its value through SchemaField with the uiSchema that names MyField, which is now applied once per data path. After: one label, Val, below the type selector, and one description.
  • MyField named by the schema's $id. Before: two labels, Val and Value. After: as above.
  • MyField named by a ui:field that it removes from the uiSchema it passes on. Before: one label, Value, and no description. After: as above.

A custom field that draws its own label and description gets both a second time on every one of those routes, the second description under the same id as its own. Leave the label and description to the value field: draw neither in the custom field, and the value field draws each once.

ui:globalOptions hold only the options RJSF reads there BREAKING CHANGE​

RJSF reads twelve options from the root uiSchema's ui:globalOptions, as fallbacks for a field that sets none of its own: addable, copyable, orderable, removable, label, allowClearTextInputs, duplicateKeySuffixSeparator, enableMarkdownInDescription, enableMarkdownInHelp, enableOptionalDataFieldForType, optionValueFormat and deprecatedHandling (#5607). They are the GlobalUISchemaOptionsKeys type. Every other option is read per field only: the Form builds its registry's globalUiOptions with pickGlobalUiOptions(), which keeps those twelve and the extension keys the form declares, and nothing else. The console warns once about each option RJSF reads per field that ui:globalOptions still sets, such as readonly or widget. A custom component hands getUiOptions() the registry's globalUiOptions, which hold the extension keys the theme and the Form declare. One that reads a root uiSchema's ui:globalOptions of its own instead passes them through pickGlobalUiOptions() with those keys, as its second argument, or it keeps only RJSF's twelve. An option set globally that RJSF no longer reads there moves as follows:

Set in ui:globalOptionsSet instead
widget, fieldthe field's own ui:widget or ui:field, or a replacement for the registered implementation through the Form's widgets or fields, such as widgets={{ TextWidget: MyTextWidget }}
A template override, such as FieldTemplatethe Form's templates
readonly, disabledthe Form's readonly and disabled props
hideErrorui:hideError at the root of the uiSchema, which reaches every field below it
help, title, description, placeholder, classNames, inputType, an array's itemLabel and any other option a field readsthe field's own uiSchema

GlobalUISchemaOptions is closed to those twelve keys, so a ui:globalOptions literal with any other key no longer compiles. Its UserExtensions parameter adds the keys your own components read, which may not reuse an option name RJSF reads for a field, or definitions or globalOptions, which only the root uiSchema's ui: keys set, as ReservedUiOptionName and RESERVED_UI_OPTION_NAMES list them. Declare the options against it, hand them to the uiSchema, which takes them as the plain type, and name their keys in the Form's new globalUiOptionsKeys prop:

import { GlobalUISchemaOptions, UiSchema } from '@rjsf/utils';

const globalOptions: GlobalUISchemaOptions<{ density?: 'compact' | 'comfortable' }> = {
label: false,
density: 'compact',
};
const uiSchema: UiSchema = { 'ui:globalOptions': globalOptions };

<Form schema={schema} uiSchema={uiSchema} validator={validator} globalUiOptionsKeys={['density']} />;

Every field then hands the declared keys to its widget in its options, beneath the field's own, as it does RJSF's, and a template reads them with getUiOptions(uiSchema, registry.globalUiOptions). A key that is an option name RJSF reads per field, such as widget, or definitions or globalOptions, which only the root uiSchema's ui: keys set, is not read from ui:globalOptions, and a warning says so.

The themes that read options of their own globally export a type for them, which takes your UserExtensions too: @rjsf/antd's AntdGlobalUISchemaOptions (block), @rjsf/chakra-ui's ChakraGlobalUISchemaOptions (chakra), @rjsf/daisyui's DaisyGlobalUISchemaOptions (daisy), @rjsf/mantine's MantineGlobalUISchemaOptions (gridCols, gridSpacing and gridVerticalSpacing) and @rjsf/mui's MuiGlobalUISchemaOptions (mui). Each of them also exports a UiSchema type whose ui:globalOptions carry its own option and your UserExtensions, so the option compiles there inline, such as { 'ui:globalOptions': { mui: { size: 'small' } } } typed by @rjsf/mui's UiSchema. Each declares its keys as its theme's globalUiOptionsKeys, a new ThemeProps field that withTheme() adds the Form's to, so a custom widget reads the theme's option from its options, and a custom template with getUiOptions(uiSchema, registry.globalUiOptions), as the theme's own do. Any other theme option is read per field only, unless the Form declares it in globalUiOptionsKeys. A theme's type also rejects a UserExtensions key its widgets read as an option of their own: @rjsf/daisyui's color, size and colorGradient, and @rjsf/mantine's wrapperProps, thumbProps, thumbLabel, valueFormat, displayFormat, colorFormat and the InputWrapper options, such as size, labelProps and inputContainer. @rjsf/shadcn exports no such type, but its RangeWidget reads props from its options and folds it into the slider's, so don't name an extension props with it. A Form from @rjsf/core handed a theme's widgets and templates directly, rather than built with withTheme() or the theme's own Form, declares none of the theme's keys, so it reads the theme's option from ui:globalOptions only when its globalUiOptionsKeys name it.

warning

@rjsf/mantine's widgets hand the options they don't recognize to their Mantine component as props, but leave out every key the registry's globalUiOptions hold that the field doesn't set itself, so a key declared for your own components, such as density, never reaches a Mantine input, while a field's own Mantine prop of the same name still does. A Mantine prop set in ui:globalOptions, such as radius or variant, reached the Mantine components of BooleanField's and ArrayField's widgets before, and now reaches none, even when declared: set it in each field's ui:options instead, or as a component's defaultProps in Mantine's own theme.

StringField, and NumberField through it, hand their widget the global options beneath the field's own, as ArrayField and BooleanField already did, so a global allowClearTextInputs or optionValueFormat now reaches every string and number widget.

LayoutGridField no longer copies the globals into each cell's uiSchema, which gave a cell's components globals no field outside a grid got. A cell's ui:options hold its own options and the ui:layoutGrid entry's props, and the cell carries no ui:globalOptions, so its widget's options no longer hold a globalOptions key. A custom field, widget or template in a cell that read a global from its uiSchema reads registry.globalUiOptions instead, as one outside a grid does, or getUiOptions(uiSchema, registry.globalUiOptions). A cell's own option, given as ui:<name> or in ui:options, now beats the global one, where the copy let a global label: false hide the label of a cell that set ui:label: true. Only the root uiSchema's ui:globalOptions are read, so one set on a nested grid's uiSchema no longer applies to that grid's cells.

A forbidden extra key says so on its row BREAKING CHANGE​

An extra key of the data that the object's additionalProperties, unevaluatedProperties or patternProperties forbid, on an object that takes extra keys, such as one matching none of the patternProperties beside additionalProperties: false, used to render a row with no input for its value and no word of why, until a submit reported must NOT have additional properties on the object (#4630). Its row now says The key "%1" is not allowed here from the first render on and right after a rename, without waiting for validation, so a snapshot of such a row gains the error. The message is the new TranslatableString.AdditionalPropertyKeyNotAllowed; a translateString that maps every key needs it. A key that only propertyNames forbids gets no such message. The key input of a forbidden key is now aria-invalid and described by the row's errors, so it is announced as invalid where the message is about it. A custom FieldTemplate that lists the props it forwards to WrapIfAdditionalTemplate one by one has to add the new keyNotAllowed prop to that list for the key to be marked. A custom WrapIfAdditionalTemplate that spreads the props it doesn't use onto an element has to leave out keyNotAllowed, which every theme's FieldTemplate now hands it, or React warns about unknown props on that element.

A dependency anyOf, or a dependency oneOf whose options don't declare the dependency's key, now applies BREAKING CHANGE​

A oneOf under dependencies is a dynamic dependency only when at least one of its options declares the dependency's key in its properties: the option whose properties[key] the data satisfies is merged in, as before. A list where no option declares the key used to be dropped with an ignoring oneOf in dependencies warning, though the validator still enforced it. It now applies as a oneOf beside dependencies does: the option the data matches is merged in, with no option selector, and getDefaultFormState() fills it the same way, so with

{
"type": "object",
"default": { "x": { "a": 1 } },
"properties": {
"x": { "type": "object", "enum": [{ "a": 1 }, { "a": 2 }], "dependencies": { "a": { "oneOf": [{ "const": { "a": 2 } }] } } }
}
}

x renders as one select that starts on { "a": 2 } under constAsDefaults always and skipOneOf, as it does when the dependency is written { "const": { "a": 2 } }, and keeps { "a": 1 } under never (#5407). Removing the list brings back the old rendering, but also drops the constraint the validator enforced.

An anyOf under dependencies is merged the same way, as an anyOf beside dependencies is: the option the data matches is merged in, where the list used to stay on the field and render an option selector. An option that declares the dependency's key is merged less that property, as the option of a dynamic dependency is, so the key's own field keeps every value it offers and changing it brings in the option the new value matches. With no selector, the form offers no way to reach an option the data doesn't match: where none matches, the first is merged, so the fields only another option declares don't render until the data matches it. This applies only to the dependencies entry's own anyOf and oneOf: an anyOf that an allOf member or a then/else of the entry holds still renders an option selector, and a oneOf there is still read as part of the dynamic dependency. So to let the user pick the option, wrap the anyOf in an allOf member of the entry, which keeps the selector, keeps the options' properties beside the key's in the data, and still applies only when the key has a value:

{
"type": "object",
"properties": { "a": { "type": "string" } },
"dependencies": {
"a": {
"allOf": [
{
"anyOf": [
{ "title": "With B", "properties": { "b": { "type": "string" } } },
{ "title": "With C", "properties": { "c": { "type": "number" } } }
]
}
]
}
}
}

Or make each option declare the dependency's key, so the key's value picks it.

GridType, Operators and TranslatableString are as const objects BREAKING CHANGE​

TranslatableString from @rjsf/utils, and GridType and Operators, which @rjsf/core now exports, are as const objects with a union type of the same name rather than enums. Their values and member access, such as GridType.ROW or TranslatableString.ArrayItemTitle, are unchanged, so code that only reads them keeps working.

Code that uses a member as a type has to name its type with typeof:

-const row: GridType.ROW = GridType.ROW;
+const row: typeof GridType.ROW = GridType.ROW;

The union type itself is still written by name, so GridType, Operators and TranslatableString as types need no change.

mergeObjects() and mergeSchemas() require an object first argument BREAKING CHANGE​

mergeObjects() and mergeSchemas() throw a TypeError when their first argument is undefined or null and the second has keys, where they used to treat it as {}. Their signatures always asked for an object, so this affects only code that called them with a value TypeScript didn't check. Pass {} instead:

-mergeObjects(maybeDefaults, overrides);
+mergeObjects(maybeDefaults ?? {}, overrides);

Where the left-hand value at a key is a primitive or an array and the right-hand one is an object, the right-hand object now replaces it, as a falsy left-hand value already did.

The root field never renders the Optional Data Controls BREAKING CHANGE​

With enableOptionalDataFieldForType, the Optional Data Controls are no longer rendered for the root field. A root anyOf/oneOf used to show its selected option behind an Add button, and now shows it straight away. A nested object or array that a recursive $ref: '#' describes was mistaken for the root and got no controls, and now gets them like any other optional field.

No code has to change. Update tests or snapshots that click the root field's Add button or expect a nested recursive field without one.

An additionalProperties entry never renders the Optional Data Controls BREAKING CHANGE​

enableOptionalDataFieldForType no longer applies to an additionalProperties entry or the anyOf/oneOf options it shows, whether it is set in ui:globalOptions, on the entry or on an option. Their Remove button could only store '', which blocked submit, so an entry is now removed by its own remove button, or stays where a layout grid names its key, which leaves it none, and its object or array no longer has the rjsf-optional-object-field or rjsf-optional-array-field class. An object or array declared inside an entry keeps its controls.

No code has to change. Update tests, snapshots or styles that use an entry's __optionalAdd or __optionalRemove button or those classes, and remove an entry with its own remove button, where it has one, instead.

@rjsf/antd's colSpan is keyed by the widget a property renders BREAKING CHANGE​

A formContext.colSpan record is now looked up by the widget each property renders, even one with no ui:widget, then by the field a ui:field or its $id names, then by its type (#5516). Before, only a ui:widget keyed a property by widget, so a plain string fell through to string and a boolean to boolean. Now a plain string, number or integer matches a text entry, a boolean a checkbox entry, an enum, a boolean with labelled options or a multiple-choice array a select entry, and an array of files a files entry, ahead of their type. A string whose format names a widget, such as date, email, data-url or hostname, matches the entry of that format's name, not text or file, even where it renders the same widget as one of those. A property whose anyOf/oneOf option renders its control matches the entry of the widget that option renders, such as textarea for a ui:widget: 'textarea' the options inherit, or email for an option with that format. One with no type of its own, such as an untyped anyOf, matches the entry of the option's type too, such as object for an object option, which also spans it full width by default, and of a field the option's ui:field names. Check a record's widget entries, such as text, email, checkbox and select, which now span properties that don't name that widget too, and change or remove each one where that isn't wanted.

A custom field that builds its ObjectFieldTemplate's properties itself sets each one's renderedAs with createSchemaFieldRenderedAs() from @rjsf/core, handing it what it hands the property's SchemaField. Without renderedAs, antd keys a property by nothing, so it gets the default span:

+import { createSchemaFieldRenderedAs } from '@rjsf/core';
+
return {
content: <SchemaField name={name} schema={propertySchema} uiSchema={propertyUiSchema} formData={propertyData} fieldPath={propertyPath} {...rest} />,
name,
hidden,
+ renderedAs: () => createSchemaFieldRenderedAs()(propertySchema, propertyUiSchema, propertyData, propertyPath, registry),
};

See renderedAs for keeping one per property across renders.

@rjsf/antd and @rjsf/mantine leave out a property hidden by deprecatedHandling or ui:definitions BREAKING CHANGE​

ObjectField marks a property hidden for its ObjectFieldTemplate whenever the property's field renders hidden, not only for the property's own ui:widget: 'hidden': also for a deprecated property under deprecatedHandling: 'hide', and for a ui:widget: 'hidden' the property's ui:definitions entry sets (#5507). The antd and mantine ObjectFieldTemplates leave out every hidden property. Before: such a property's field rendered inside its theme's hidden wrapper (antd's rjsf-field-hidden div, mantine's display: none Box), in a column or grid cell of its own. After: it doesn't render, and the other properties share the row as they do beside a property with its own ui:widget: 'hidden'. The property's value stays in the form data, but its input is no longer in the DOM, so a native submit or new FormData(form) no longer posts it. A test that looked the input up reads the value from the form instead:

- expect(container.querySelector('#root_b')).toHaveValue('legacy');
+ expect(formRef.current?.getFormData()).toMatchObject({ b: 'legacy' });

A $ref cycle whose field renders hidden has no Expand button BREAKING CHANGE​

CyclicSchemaField renders nothing in place of its Expand button for a $ref cycle whose field one level further renders hidden, since expanding it would show nothing, and ObjectField marks such a property hidden, expanded or not (#5507). That is a cycle whose own uiSchema or ui:definitions entry sets ui:widget: 'hidden', or, under deprecatedHandling: 'hide', one that is deprecated beside its $ref or where the $ref leads. With this schema and uiSchema: { child: { 'ui:widget': 'hidden' } }, before, #root_child__expand rendered, and clicking it rendered a hidden field. After, neither renders.

{
"definitions": {
"node": { "type": "object", "properties": { "child": { "$ref": "#/definitions/node" } } }
},
"$ref": "#/definitions/node"
}

A test that clicked the button to reach that field reads the field's value from the form data instead.

An array item's handlers do only what its buttons can BREAKING CHANGE​

onAddClick, onAddItem, onCopyItem, onRemoveItem, onMoveUpItem and onMoveDownItem now do nothing where the button can't act, whatever the template draws (#5677). That covers the first item up, the last item down, moving or removing a fixed tuple item, adding after or copying one that isn't the last, adding to an optional array without data, whose data its optionalDataControl starts, a disabled or read-only array or item (an item's own lock leaves onAddItem, as it leaves the add button), and an option or limit that takes the button away, such as ui:orderable: false, ui:removable: false, ui:copyable left off or maxItems. maxItems and the last item are checked against the items at the call, so a template can call onRemoveItem() and then onAddItem() in one handler at the limit, or onAddItem() and then onMoveDownItem() on the last item, and a second onAddItem() past the limit does nothing. buttonsProps.canAdd tells whether onAddItem acts: it is false on a disabled or read-only array, and after a tuple's fixed item that isn't the last, even where the array's add button shows. The array's own canAdd still tells only that the array is addable and has room, since the add button shows, disabled, on a disabled or read-only array. An ArrayFieldTemplate that renders its own add button has to disable it on disabled || readonly itself, as the built-in ones do, or the button does nothing when pressed. An ArrayFieldItemButtonsTemplate that renders its own buttons likewise shows each only under its hasMoveUp, hasMoveDown, hasCopy or hasRemove and disables it on disabled || readonly, or a button that can't act stays on screen and does nothing, where Remove on a disabled or read-only array used to remove the item. A refused call still prevents the event's default, so a button that isn't type='button' doesn't submit the form.

A template that hides core's arrows with ui:orderable: false and reorders through these handlers, as a drag-and-drop template does, now gets no move. Leave ui:orderable on and leave the arrows out of your own ArrayFieldItemButtonsTemplate instead:

- uiSchema = { 'ui:orderable': false };
+ templates = { ArrayFieldItemButtonsTemplate: MyButtonsWithoutArrows };

Copy shows only from a tuple's last fixed item on BREAKING CHANGE​

In an array with fixed items and additionalItems, core's Copy button no longer shows on a fixed item before the last (#5677). A copy goes below its item, so a copy of an earlier fixed item pushed the fixed items after it into positions of another schema. Copy shows on the last fixed item and on the additional items after it.

A fixed item before the last whose only button was Copy now has none, so its hasToolbar is false and templates drop its toolbar, which changes its layout: core's leaves out the rjsf-array-item-toolbox element, and @rjsf/bootstrap3's widens its content from col-xs-9 col-md-10 col-xl-11 to col-xs-12, so its inputs no longer line up with those of the items that keep buttons. Snapshots and CSS that relied on that toolbar change with it.

Under itemLabel, items are named only when they have buttons, and a fixed item before the last, which can't be moved or removed, now has none, so the numbers that tell items of one name apart no longer count it. In a tuple of two fixed items with an additional one, all three named x, the Copy buttons were Copy x (1), Copy x (2) and Copy x (3), and are now Copy x (1) on the last fixed item and Copy x (2) on the additional one, whose Remove button is Remove x (2) too.

A test that finds a fixed item's Copy button, or a button by one of those numbered titles, finds it on the item that still shows it, by its new number.

A disabled or read-only array item disables its own buttons BREAKING CHANGE​

An array item that is disabled or read-only on its own, not through the array, now has its move, copy and remove buttons disabled too, the way its inputs are (#2504). That is an item made so by the items or additionalItems uiSchema (ui:disabled or ui:readonly, static or from the items function), a ui:definitions entry for the items' $ref, or a readOnly in the item's own schema. A readOnly on its selected anyOf/oneOf option locks only the inputs. Before, only the inputs were locked, and the item could still be moved, copied and removed. A deprecated item that deprecatedHandling: 'disable' disables keeps its buttons, so it can still be removed. An item's own ui:disabled: false or ui:readonly: false under a disabled or read-only array, or form, still enables only its inputs, and the buttons stay disabled. ArrayFieldItemTemplate's disabled and readonly are the same as its buttons', so they are now true for such an item too, where they used to be only the array's.

The add button stays enabled, and the row it adds is locked as soon as it appears. That works when the items have a default. Without one the row can't be filled or removed and it fails validation, so set 'ui:options': { addable: false } on the array.

To keep an object item's rows movable and removable while its values stay read-only, put readOnly on its properties instead of on the item:

items: {
type: 'object',
- readOnly: true,
- properties: { name: { type: 'string' } },
+ properties: { name: { type: 'string', readOnly: true } },
},

Move a ui:readonly or ui:disabled from items onto its properties the same way, as items: { name: { 'ui:readonly': true } }. The item's lock reached every property, its additionalProperties and any nested array's buttons, so give each of them its own readOnly or ui:readonly to keep them locked; a property left without one becomes editable, and a nested array's add, move and remove buttons become enabled. The add button of an item with additionalProperties becomes enabled too, and no property's readOnly locks it, so remove it with 'ui:options': { expandable: false } on items.

A list of plain values, such as items: { type: 'string', readOnly: true } or items: { 'ui:readonly': true } on a list of strings, has no properties to move the lock onto, and ui:readonly: false on the items unlocks their inputs too. The same goes for ui:disabled on such a list. There is no setting that keeps such rows movable and removable while their values stay read-only or disabled. Render the buttons with a custom ArrayFieldItemButtonsTemplate that leaves out readonly, or disabled for ui:disabled, keeping in mind that it then also enables them in a read-only or disabled array or form.

sanitizeDataForNewSchema() removes the keys it discards, and leaves out objects the data doesn't hold BREAKING CHANGE​

This affects code that calls sanitizeDataForNewSchema(), or schemaUtils.sanitizeDataForNewSchema(), and reads what it returns. Form and its anyOf/oneOf switch already handle the new shape.

These placeholders it used to write are gone, at every level of the data:

  • A key the old schema declares and the new one doesn't, or declares with another type, is removed. Before, it was kept holding undefined.
  • An object property the data doesn't hold, or holds as null where neither its type list nor nullable: true allows null, is left out. Before, it was written as {} wherever its schema declares properties, and a null was kept where it declares none.
  • A key the new schema still declares but whose value it rejects, through its const, its enum, a readOnly default or a prefix item the array can't hold, is removed, so the defaults fill it in. An array keeps the items past its prefix that its new items allow, and drops the rest. A key the data already holds as undefined keeps it where the old schema declares the same type, const, enum or constant options, maxItems and default for it, the default the object declares for it included, and is otherwise removed too unless a value replaces it, as a top-level clear is when anyOf/oneOf options are switched. Before, it was set to undefined, which kept the defaults from reaching it.

With old = { type: 'object', properties: { x: { type: 'object', properties: { p: { type: 'string' } } } } }, newSchema the same with q in place of p, and the data { x: { p: 'v' } }:

-{ x: { p: undefined } }
+{ x: {} }

With newSchema = { type: 'object', properties: { k: { type: 'string' }, opt: { type: 'object', properties: { x: { type: 'string' } } } } } and the data { k: 'a' }:

-{ k: 'a', opt: {} }
+{ k: 'a' }

If you spread the result over the old data to clear what it discarded, the spread no longer clears those keys. Use the result on its own instead:

-const next = { ...oldData, ...schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, oldData) };
+const next = schemaUtils.sanitizeDataForNewSchema(newSchema, oldSchema, oldData);

If you need the objects for a schema's optional object properties, fill them in with getDefaultFormState(), as a mount does. In a test that asserts the result, drop the undefined keys and the {} placeholders from the expected value. toEqual() already treats a key holding undefined as absent, but it doesn't ignore a {}.

New Features​

getFormData() and the FormRef type​

The form owns its data, the way an <input> with a defaultValue does (see Form owns its data), and until now the only way to see it was onChange, so autosave, an "unsaved changes" guard, or a submit button rendered outside the form all had to copy every change into your own state. getFormData() reads it on demand:

function Editor({ record }) {
const formRef = useRef<FormRef>(null);

useEffect(() => {
const timer = setInterval(() => api.draft(formRef.current?.getFormData()), 30_000);
return () => clearInterval(timer);
}, []);

return <Form ref={formRef} schema={schema} validator={validator} initialFormData={record} />;
}

It returns the form's current value: the seed, filled in with the schema's defaults, and every edit made since, including one React has yet to show in the inputs.

FormRef<T> names every supported ref member. The ref section above shows how to type a ref against it.

transformChange​

A pure (next, previous) => data prop that decides what an edit leaves the form with: next takes the edit, previous refuses it, and any other value rewrites it, before the form stores, validates and reports the result. It replaces refusing an edit by not storing it, which no longer works now that the form owns its data. See Refuse or rewrite an edit in transformChange.

ui:initialValue and ui:required​

Two new uiSchema directives complement the extended ui:emptyValue above, for forms that want to control a field's initial value or required-ness without editing the underlying schema:

  • ui:initialValue pre-fills a field on initial render and after a form reset. It takes priority over schema.default, but never overrides formData that's already been provided. This is useful for a field — often hidden — that a particular form wants to fix to a known value, e.g. a country field defaulted to 'US' for a US-only form.
  • ui:required overrides a field's required status from the UI side only. Setting it to true shows the required indicator and adds the field to the effective required set used for validation, even if the schema doesn't mark it required. Setting it to false hides the indicator on a schema-required field, but does not suppress schema-level validation — pair it with ui:initialValue or ui:emptyValue so the field always has a value, or you'll get a console.warn. ui:required must be set per field; it is not honored via ui:globalOptions.

See ui:initialValue and ui:required for details and examples.

ui:required is enforced by walking the rendered form itself rather than by rewriting the schema handed to your validator, so it works the same way regardless of which validator you use — including the precompiled validators (AJV8PrecompiledValidator from @rjsf/validator-ajv8 and ATAPrecompiledValidator from @rjsf/validator-ata), which enforce it too. The one difference from a schema-level required error: a ui:required error is built by RJSF, not your validator, so it doesn't pass through a custom transformErrors function and doesn't carry validator-specific fields like AJV's params.missingProperty. See ui:required for details.

ui:options.optgroups for grouped <select> options​

Every theme's SelectWidget now supports grouping enum options into labeled sections (rendered as <optgroup> elements, or each theme's closest equivalent — see the optgroups docs for the specifics per theme) via ui:options.optgroups:

const uiSchema: UiSchema = {
'ui:options': {
optgroups: {
Latin: ['lorem', 'ipsum', 'dolorem'],
Greek: ['alpha', 'beta', 'gamma'],
},
},
};

Keys are the group labels; values are arrays of enum values belonging to that group. Enum values not listed in any group render ungrouped after the groups. This is purely additive — existing forms are unaffected unless optgroups is set.

propertyNames.enum renders the key as a dropdown​

An object whose propertyNames enumerates the allowed key names now renders each additionalProperties/patternProperties key as a dropdown of those names rather than a free-text input, in every theme:

const schema: RJSFSchema = {
type: 'object',
additionalProperties: { type: 'boolean' },
propertyNames: { enum: ['darkMode', 'betaBanner', 'offlineCache'] },
};

A name another key already holds is left out of the dropdown, and the add button creates the new property under the first allowed name that is still free instead of newKey. registry.schemaUtils.canExpand() hides the add button once every allowed name is taken, the way it already does at the maxProperties limit, so a custom ObjectFieldTemplate that calls it picks this up on its own. A propertyNames without an enum, such as one constraining names by pattern, keeps the free-text input, so existing forms are unaffected.

The names reach WrapIfAdditionalTemplate as a new optional propertyNamesEnum prop on FieldTemplateProps/WrapIfAdditionalTemplateProps. A custom FieldTemplate that lists the props it forwards to WrapIfAdditionalTemplate one by one, rather than spreading them, has to add propertyNamesEnum to that list for the dropdown to appear, and keyName for the key it renders to be the real one (see FieldTemplateProps gains a required keyName). A custom WrapIfAdditionalTemplate renders the dropdown by handing the prop to the new AdditionalPropertyKeySelect component from @rjsf/utils, which resolves the theme's own SelectWidget:

{propertyNamesEnum ? (
<AdditionalPropertyKeySelect
id={additionalKeyId(id)}
label={keyLabel}
value={keyName}
propertyNamesEnum={propertyNamesEnum}
onKeyRename={onKeyRename}
disabled={disabled}
readonly={readonly}
required={required}
registry={registry}
/>
) : (
<input type='text' id={additionalKeyId(id)} onBlur={onKeyRenameBlur} defaultValue={keyName} />
)}

Multiple types and unconstrained properties can be edited through the fallback UI​

JSON Schema lets a type be a list of types. v6 rendered the first type in that list and gave no way to reach the others, and it rendered a property described only by additionalProperties: true as a field for whatever type the data happened to hold, with the same one-way door. Turning on the useFallbackUiForUnsupportedType prop now renders both through FallbackField, which pairs a type selector with the field for the type currently chosen:

const schema: RJSFSchema = {
type: 'object',
properties: {
// a selector offering exactly these three types
multi: { type: ['string', 'number', 'boolean'] },
// a selector offering every JSON Schema type, for each key added here
anything: { type: 'object', additionalProperties: true },
},
};

render(<Form schema={schema} validator={validator} useFallbackUiForUnsupportedType />, document.getElementById('app'));

Switching the type casts the value that is already there where it can (a number to its string spelling, a string to the number it reads as) and starts from an empty value where it cannot, such as an object, an array, or a number from text that reads as no number at all. The selector also now offers integer and null alongside the five types it offered in v6, and starts on the type the current form data has. Every other keyword of the schema still describes the value, so a union declaring properties, items, format or minimum keeps them for whichever type is in effect.

A schema pairing multiple types with an anyOf or oneOf whose options name no type gets both selectors: the type selector wraps the option selector, and each option renders for whichever type is currently chosen. Every member of the union is therefore reachable from within an option, where in v6 an option could only ever be the first type in the list. A list with an option naming a type of its own gets no selector around it, and any option in it naming no type renders a type selector of its own, unless the list mixes the two on a schema whose first type is object, or names only its own types on such a schema whose ui:field or $id names the fallback field.

Four things are deliberately unchanged:

  • A nullable type (['string', 'null']) still renders as a plain string field, since getSchemaType() resolves it to that single type. Only a union of more than one non-null type gets a selector.
  • With the prop off (the default), there is still no selector, since it is the opt-in fallback UI, so a union renders one field for one of its types. That is the first type in the list that JSON Schema defines other than null, so ['null', 'string', 'number'] renders its string and ['foo', 'string'] its string rather than the unsupported-field box. A ui:widget that only another listed string, number, integer or boolean type has renders that type's field instead, so a textarea on a ['number', 'string'] renders StringField where v6 threw; a format picks no other type's field. A union paired with an anyOf or oneOf stays one-way too, since the option selector alone cannot reach the other types.
  • A schema pinning its value with an enum, a const, or an anyOf/oneOf of constants renders as the select or constant field it always has, with no type selector, since switching type would only cast a value the user picked into one the schema rejects.
  • A field whose ui:widget is a component renders through that control alone, with no type selector, the same way one with a ui:field does. A control written for that schema handles the types it allows, so wrapping it would pin the schema to one of them and cast the value on every switch. A ui:widget naming a widget by string is a theme's control for a single type, which is the choice the selector is there to make, so it renders within the selector instead — and is dropped for a chosen type the theme has no implementation of. A schema with no usable type of its own — an unrecognized type, or an additionalProperties entry left unconstrained — still gets a selector, since it lists no types for such a control to handle, and the widget renders within it for whichever type is chosen. A list naming an unrecognized type alongside a recognized one offers only the recognized ones, null included: a ['foo', 'null'] offers null and nothing else.

See Multiple types for details.

additionalPropertiesWhenAbsent reads an object naming no additionalProperties as the spec does​

JSON Schema reads an object that names neither additionalProperties nor unevaluatedProperties as taking any extra key, as additionalProperties: true does, where the form has always read it as taking none (#2137). The new additionalPropertiesWhenAbsent prop, 'forbid' by default, has the form follow the spec when set to 'allow': such an object gets an add button, the extra keys of its data render with their key input, and omitExtraData keeps them. An object anyOf/oneOf union then renders its own title and add button beside the option it shows; give it additionalProperties: false to keep it closed. See Objects naming no additionalProperties.

Utilities no other RJSF package uses are no longer exported from @rjsf/utils BREAKING CHANGE​

This affects code that imports one of the 6.x exports below from @rjsf/utils, which now exports only what another RJSF package uses. The ones RJSF still needs are unchanged, just no longer exported; the rest are deleted.

ExportUse instead
canExpand()registry.schemaUtils.canExpand(schema, uiSchema, formData)
ariaDescribedByIds()getControlDescribedBy({ id, presentation }), or widgetAriaProps() for a widget; for ariaDescribedByIds(id, true), which also named the examples list, pass [examplesId(id)] as getControlDescribedBy()'s second argument
dataURItoBlob()useFileWidgetProps() in a file widget, which gives each file's name, size and type; elsewhere, split the URL at ;base64,: the part before holds the type up to its first ; and the file name in a URI-encoded name= parameter, and new Blob([Uint8Array.from(atob(base64), (c) => c.charCodeAt(0))], { type }) builds the blob. fetch(dataURL) keeps the name= parameter in the blob's type and is blocked by a connect-src policy that doesn't allow data:
dateRangeOptions(), getDateElementProps(), parseDateString(), toDateString(), pad()useAltDateWidgetProps() and DateElement, which build the date parts; outside a widget, the UTC getters of new Date(value) for parseDateString(), adding 1 to getUTCMonth() for its 1-based month (it gave -1 for every part of an empty value, threw Unable to parse date <value> for a string new Date() can't parse, where the getters give NaN, and gave 0 for the hour, minute and second when includeTime was false, where the getters give the parsed ones), new Date(Date.UTC(year, month - 1, day, hour, minute, second)).toJSON() for toDateString(), followed by .slice(0, 10) for toDateString(date, false), and String(value).padStart(width, '0') for pad()
findSchemaDefinition()registry.schemaUtils.retrieveSchema({ $ref }) in a field or widget. Without a registry, or for a $ref into another root, retrieveSchema({ validator }, { $ref }, rootSchema). Both need a validator, and both resolve more than the definition: an allOf in it is merged and its if/then/else and dependencies are resolved against no data, and the result carries RJSF's Symbol markers. A $ref inside the definition is left as it is, and a $ref the root doesn't define throws, as before.
getInputProps()getBaseInputProps(), which takes a BaseInputTemplate's props: outside a template, pass it { schema, type, options, registry }, where registry needs only translateString, and pass getInputProps()'s fourth argument as its second, plainNativeInput, which also decides whether a number/integer field gets a text input with a numeric inputMode and pattern. It returns more than getInputProps() did: a title naming that numeric pattern, an accept, inputMode or pattern passed in its props in place of the derived one, and multiple set to undefined for any input but a file input, so it overrides a multiple spread before it, such as on an email input
getTemplate()getTemplates(registry, uiOptions), destructuring the template you need. It resolves only the names the registry holds: an override in ui:options under a name the registry has no template for, which getTemplate() returned, is left out, so read such a template from uiOptions yourself and name its props with isComponentType<P>()
getWidget()resolveWidget(schema, widget, registry.widgets), destructuring its Widget
hashForSchema()hashObject(), which hashes the schema the same way
hashString(), sortedJSONStringify()Nothing: copy their 6.x implementation. hashObject(x) is hashString(sortedJSONStringify(x)), so it neither returns the JSON nor hashes a string as hashString() did: hashObject('abc') hashes "abc", quotes included
mergeDefaultsWithFormData()getDefaultFormState(), or mergeObjects() for plain objects, which replaces an array where mergeDefaultsWithFormData() merged its items and has nothing in place of its mergeExtraArrayDefaults, defaultSupercedesUndefined and overrideFormDataWithDefaults arguments, so an undefined value always overwrites its default
replaceStringParameters()englishStringTranslator() for a TranslatableString; for a string of your own, replace %1, %2, … yourself
schemaParser()getValidationSchemas()
withIdRefPrefix()withRootIdRefPrefix(schema, {}), which prefixes a local # $ref with ROOT_SCHEMA_PREFIX and leaves a relative one as written, as 6.x did. Passing the real root instead gives a root with an $id that $id as the prefix, resolves relative $refs against it and re-spells those inside a nested $id, so a validator holding the root under ROOT_SCHEMA_PREFIX can't resolve them. Unlike 6.x, it takes only a schema object, not an array or a primitive
DEFINITIONS_KEY, DEPENDENCIES_KEY, ENUM_KEY, IF_KEY, DISCRIMINATOR_PATH, FORM_CONTEXT_NAMEThe string or path they hold: 'definitions', 'dependencies', 'enum', 'if', ['discriminator', 'propertyName'], 'formContext'
isValueEmpty(), NAME_KEY, RJSF_PREFIX_KEYNothing: they served RJSF's own code

New types​

The following new types were added to @rjsf/utils:

  • AdditionalPropertyKeySelectProps<T, S, F>: the props of the AdditionalPropertyKeySelect component.
  • ArrayFieldItemButtonProps: the props ArrayFieldItemButtonsTemplateProps.itemButtonProps hands each of an array item's copy, moveDown, moveUp and remove buttons, holding a key only for the buttons the item can show.
  • Bivariant<Args, R>: a callback whose parameters are checked bivariantly, the way React checks event handlers, so an implementation may declare narrower parameters than the callback's own. SlotComponent and the onChange props are built from it, and the CompiledValidateFunction of @rjsf/validator-ajv8 and of @rjsf/validator-ata extends it.
  • ClassMap: the class names a theme or a form gives the elements @rjsf/core renders, one key per element. See Registry has a required classMap.
  • CoreUiOptionsChecks (@rjsf/core): The UiOptionsCheck union describing @rjsf/core's own built-in widgets, fields and ui:options. Pass it as StrictUiSchema's Checks argument (union it with your own Checks to extend it further) - it isn't included automatically.
  • CustomTemplateEntries: the part of TemplatesType holding custom templates, alone or in named groups, under its string index signature. See The templates prop of Form and withTheme() checks declared templates.
  • DateTimeLocalValueResult: what getDateTimeLocalValue() returns: isIsoDateTime, requiresOffset and the localValue a datetime-local input displays.
  • DeclaredTemplatesType<T, S, F>: the part of TemplatesType holding the templates RJSF declares, each typed with its own props, without the index signature for custom ones. See The templates prop of Form and withTheme() checks declared templates.
  • EventFormData<T> (@rjsf/core): the type of IChangeEvent.formData and FormRef.getFormData(). See Generic defaults are unknown instead of any.
  • FieldPath: a branded string identifying a field by the path to its data, such as friends[0].firstName. See Field identity.
  • FormClassMap: the classMap a Form takes, a ClassMap whose entries may also be a function of the class beneath, so a form can extend that class rather than repeat it. See Registry has a required classMap.
  • FormFieldState<S>: what a form shows beyond its schema and data, which getDefaultFormState(), getUiRequiredErrorSchema() and omitExtraData() take as an optional fieldState: selectedOption(), the option each field shows, isExpanded(), whether the user expanded a collapsed $ref cycle, the optional drawsControlsBesideOptions(), whether an object draws its Optional Data Controls beside its anyOf/oneOf options, the optional fallbackType(), the type the fallback UI's type selector is on for a field it renders, and the optional rendersArrayWidget(), whether ArrayField renders a field as one widget. getDefaultFormState() computes a field's defaults from the option it shows, and the other two walk the fields on screen. Form passes its own. See FormFieldState.
  • GetDefaultFormStateProps<T, S, F>: the props object getDefaultFormState() takes after the SchemaContext.
  • GlobalUISchemaOptionsExtensions<E, Reserved>: checks the keys an extension of GlobalUISchemaOptions adds, mapping each one RJSF reserves, or a string index signature, to never. See ui:globalOptions hold only the options RJSF reads there.
  • GlobalUISchemaOptionsKeys: the options RJSF reads from ui:globalOptions, as fallbacks for a field that sets none of its own. See ui:globalOptions hold only the options RJSF reads there.
  • GroupRole: 'radiogroup' | 'group', the role getGroupAttributes() gives the element wrapping a group of controls.
  • IndexedEnumOptionType<S>, EnumOptionsGroupType<S>, GroupedEnumOptionsType<S>: the option/group shapes returned by groupEnumOptions().
  • LogOnceLevel: 'warn' | 'error', the console method logOnce() logs through.
  • MarkdownTemplateProps<T, S, F>: the props a MarkdownTemplate receives, generic over the same <T, S, F> as every other template's props. children is the markdown string to render; uiSchema and registry are picked from the form's base props, so an implementation can read ui:options, reach registry.translateString, or fall back to another template.
  • MergedClassMap: a form's ClassMap once buildRegistry() layered the Form's over the theme's over @rjsf/core's, holding every key, undefined where no layer gives the element a class. See Registry has a required classMap.
  • PartialTemplatesType<T, S, F>: any subset of the templates, as the templates prop of Form, a theme's templates and getTestRegistry() take: each declared template, each button of ButtonTemplates on its own, and custom entries. See The templates prop of Form and withTheme() checks declared templates.
  • PresentationDeclaration, ThemePresentation, FieldPresentation: the presentation metadata proposed in #5490, which @rjsf/core and every theme read. A PresentationDeclaration names who draws each part of a field (a PresentationPart: its label, description, errors and help) as a PartOwner: 'template', 'self' or, for a field that renders a single widget, 'widget', plus labelledBy, whether a label the template draws reaches the control through its for or through the control's aria-labelledby. A widget's declarations, a ThemePresentation's widgets and default, are WidgetPresentationDeclarations, which can't name the 'widget' owner; its default and fieldDefault take no uncheckedIsFalse, which is declared under the key of the one widget it describes. A ThemePresentation holds declarations by registry key under widgets and fields, plus default for the parts of every widget that the layer's own entry for it doesn't declare, custom and unregistered widgets included, and fieldDefault for the parts of every field that its merged entry doesn't declare, custom fields included, which is also what a part a field leaves to its widget falls back to when it renders none; mergeThemePresentation() merges a form's layers into a MergedThemePresentation. A FieldPresentation is one field's resolved plan: each part as a PartPresentation, placed 'template', 'self', 'above' (drawn by a field above rendering the same id) or 'none' (a PartPlacement) under the id it renders with, the describedBy ids of its parts that render, which a field below rendering the same id inherits, the showLabel its ui:label resolved to, its label's requiredMarker, planned for a container's heading too and carried by an above label from the label drawn above, its control's ControlAttributes, and the inherited plan of the field above rendering the same id. ResolvedPresentationDeclaration is what getPresentationDeclaration() returns, with every part settled on 'template' or 'self'. Nothing is declared on a component: one given directly, as a ui:field or ui:widget, takes the declaration for the first key it is registered under that declares it.
  • PresentationSource<T, S, F>, PresentationSubject<T, S, F>, FieldPresentationInputs, FieldLabelSources<S>: the parameter types of getPresentationDeclaration(), resolveFieldPresentation() and getFieldLabel().
  • RawValidationResult<E>: what ValidatorType.rawValidation() returns: the engine's errors, typed E, and the validationError thrown when validation couldn't run. See any members of the public types are specific types.
  • ReservedUiOptionName: the names an extension of GlobalUISchemaOptions may not reuse: every option name RJSF reads for a field, and definitions and globalOptions, which only the root uiSchema's ui: keys set. See ui:globalOptions hold only the options RJSF reads there.
  • SchemaContext<S, F>: the validator, customMergeAllOf, defaultFormStateBehavior and additionalPropertiesWhenAbsent every validator-based schema function takes as its first parameter. See @rjsf/utils schema functions take a SchemaContext in place of the validator.
  • SchemaFlagMarker: the flag markers hasSchemaMarker() reads, ADDITIONAL_PROPERTY_FLAG, GUESSED_TYPE_FLAG and RJSF_REF_CYCLE_KEY, for a function that wraps it.
  • SlotComponent<P>: the component-slot type behind Widget, Field and every TemplatesType entry: a function or class component taking P, with its props checked bivariantly. See Widget type.
  • StrictUiSchema<Checks, T, S, F>: the opt-in form of UiSchema that narrows ui:widget/ui:field/ui:options to a Checks union's vocabulary. See Type-safe UiSchema (opt-in).
  • ThemedForm<T, S, F> (@rjsf/core): what withTheme() returns. See withTheme() returns a ThemedForm.
  • ThemeGlobalUISchemaOptions<ThemeExtensions, UserExtensions>: the ui:globalOptions of a theme that reads options of its own, whose extensions may not reuse an option name RJSF reads, and a user's may not reuse the theme's either. See ui:globalOptions hold only the options RJSF reads there.
  • UiOptionsCheck: The { when, then } shape a Checks union is built from, each rule declaring the widget/field/option vocabulary StrictUiSchema allows for a given form-data type.
  • UseOptionFocusHandlersResult: what useOptionFocusHandlers() returns: the focusHandlers and blurHandlers of each option, by position.
  • UseSelectFocusHandlersResult: what useSelectFocusHandlers() returns: its handleFocus and handleBlur.
  • UseTimeWidgetPropsResult: what useTimeWidgetProps() returns: isIsoTime, the localValue a time input displays, and computeTimeValue().
  • VisibleErrorsProps: the parameter type of hasVisibleErrors(), deprecated with it. See A field's label, description, help and errors are drawn where its plan says.
  • WidgetAliasFor<Type>: the ui:widget alias names resolveWidget() accepts for a JSON Schema type, such as 'text' | 'textarea' | 'password' | ... for WidgetAliasFor<'string'>.
  • WidgetAriaPropsInput<T, S, F>: the props widgetAriaProps() builds a SelectWidget's accessible name and description from.

Type-safe UiSchema (opt-in)​

StrictUiSchema<Checks, T, S, F> is a new, opt-in form of UiSchema: pass it a Checks union and it narrows ui:widget/ui:field/ui:options to only the values valid for each field's form-data type. An inline uiSchema JSX literal gets this narrowing too, by checking it with TypeScript's satisfies operator - no wrapper component needed.

UiSchema is otherwise unchanged - using StrictUiSchema is entirely opt-in and can be done incrementally, field by field or form by form.

See Type-safe UiSchema for the full documentation, including examples, how to extend the widget/option vocabulary via Checks, and the current known gaps.

New utility React components​

Every React component @rjsf/utils adds in v7 is listed here alphabetically, each linking to its entry in the API reference, which documents it.

New utility functions​

Every function @rjsf/utils adds in v7 is listed here alphabetically, each linking to its entry in the API reference, which documents it.

@rjsf/utils also exports new constants, all described in Constants in the API reference, among them WIDGET_FIELD_PRESENTATION and DEFAULT_BOOLEAN_WIDGET (#5456), and JSON_SCHEMA_TYPES, every JSON Schema type name in the order the fallback UI offers them (#5323).

Changes to existing utility functions​

  • Every validator-based schema function: Takes a SchemaContext in place of its validator, and no longer takes a customMergeAllOf or defaultFormStateBehavior. See @rjsf/utils schema functions take a SchemaContext in place of the validator
  • enumOptionSelectedValue(): In the realValue format the selected value is encoded the same way enumOptionsDomValues() encodes the options' values, instead of with String(), so a selected object or null value now matches its option. It also no longer reports "no selection" purely because the value equals the emptyValue a widget passed it, so an option carrying that widget's null or '' sentinel shows as selected. An entry of a multiple selection that matches no option is left out, as the indexed format leaves it out
  • enumOptionsIsSelected(): Takes an optional third multiple argument. A single-select widget passes false, so an array selected is compared as the whole value of an array option such as { "const": [2] } rather than as a list of selections; omitted, an array is still a list. enumOptionsIndexForValue() prefers an option matching the whole value when not multiple, which fixes enumOptionSelectedValue() for array constants too, and the radio widgets pass false
  • enumOptionsSelectValue(): Compares values by deep equality and accepts a null option, so an object option selected in a checkbox list lands in the options' order, whether or not the form data holds the option's own instance It no longer adds a value that is already selected, and keeps a selected value that matches no option after the ones that do
  • enumOptionValueDecoder(): In the realValue format a string decodes to the option enumOptionsDomValues() encodes as that string, and no longer falls back to the option at a bare index no option encodes as. That fallback returned the wrong option whenever a number option shared its value with another option's position, e.g. '1' decoded to 1 rather than 5 for the options 1 and 5. A widget that holds an option's position resolves it with enumOptionsValueForIndex() instead.
  • getDefaultFormState(): Takes a SchemaContext followed by a GetDefaultFormStateProps object, and added optional uiSchema and uiSchemaDefinitions props used to apply ui:emptyValue and ui:initialValue as defaults
    • These were added as optional trailing parameters of the SchemaUtilsType interface's version of getDefaultFormState() as well
  • getDefaultFormState(): Under nestedDefaultsPrecedence: 'ancestorWins', an ancestor's default wins only where the property's types can hold it, falsy or not. An ancestor's 0, false, '', or a null the property's type allows, wins over the property's own default, where the property's default won, and an ancestor's value of a type the property can't hold gives way to the property's default: default: { v: 5 } over v: { type: 'string', default: 'x' } gave { v: 5 } and gives { v: 'x' } (#5624).
  • getDefaultFormState(): Drops object form data a schema whose types leave out object holds, such as { type: 'string' } or { type: 'array' }, and matches object data at every depth, leaving out a property whose data doesn't match. Object data is a plain object with no primitive value of its own, so a Date, or an object whose valueOf() gives an epoch, is kept. Such a field takes its defaults, as one the data lacks, except inside the items of array data, which keep it. Inside object data, a select over object constants, or a type: 'string' select whose enum holds objects, whose data is none of its constants takes its default, and with no default its key is left out: { p: { type: 'object', enum: [{ a: 1 }, { a: 2 }] } } with { p: { a: 3 } } gave { p: { a: 3 } } and gives {}. Add the value to the options to keep it, or give the select a default to replace it (#5522).
  • getDefaultFormState(): Once the form has generated its defaults (initialDefaultsGenerated), the object data of an untyped field is kept as it stands, so the field's own object default no longer fills in the keys its data lacks: { default: { a: 1 } } with { b: 2 } gave { a: 1, b: 2 } and gives { b: 2 }, as a type: 'object' field with that default already did. Give the field type: 'object' and declare the keys as properties with defaults of their own if they should be filled in (#5523).
  • getDisplayLabel(): An object or array schema whose enum or anyOf/oneOf is a non-empty list of constants now displays its label, since it renders as one select for the whole value rather than as the fields of its contents
  • getOptionMatchingSimpleDiscriminator(): Removed. It read the discriminator property only from an option's own properties, so for the options [{ $ref: '#/definitions/A' }, { $ref: '#/definitions/B' }] whose kind is const 'a' and 'b', the data { kind: 'b' } and the discriminator kind it returned undefined. Call getFirstMatchingOption() or getClosestMatchingOption() with the discriminatorField, which read it through the option's $ref, its allOf entries and the rest of its scope, and return 1 there.
  • getPropertySchema(): Reads properties by own key, so a property named like an Object.prototype member that the schema doesn't declare, such as toString or constructor, returns {} rather than the inherited function (#5561)
  • getRenderedOptionSchema(): The option of an object parent is marked with a Symbol flag, beside the required list and type it already took, so its own field offers no extra key beside the parent's even where the form reads an absent additionalProperties as taking any key
  • getSchemaType(): A schema without a type whose const is falsy (0, false, '' or null) now reports the constant's type instead of undefined. See A typeless oneOf/anyOf of constants now renders as a select for what this changes. A type list of three or more entries that starts with null, such as ['null', 'string', 'number'], now resolves to its first type other than null rather than to null, as a two-entry nullable list already did, so it renders that type's field rather than NullField and getDefaultFormState() fills it in as that type: a required list whose first type other than null is boolean starts at false, and one whose first is object or array gets that type's computed defaults. Of the types any list names, it prefers one JSON Schema defines, so a ['null', 'foo', 'string'] resolves to string, and a ['foo', 'string'] to string rather than foo. Its return type narrows from string | string[] | undefined to string | undefined, since it never returned an array, so a caller's Array.isArray() check on it can go (#5331)
  • isRootSchema(): Removed. It compared a schema's content with the root schema's, which is what made shouldRenderOptionalField() mistake a nested field for the root. Compare the field's fieldPath with ROOT_FIELD_PATH instead: fieldPath === ROOT_FIELD_PATH
  • isSelect(): Reads anyOf before oneOf, the same as optionsList(), so a schema carrying both keywords is judged by the list that renders (#5309). sanitizeDataForNewSchema() and findFieldInSchema() read anyOf first too, and omitExtraData() checks the oneOf/anyOf list it prunes by rather than asking isSelect() about the parent
  • optionsList(): Returns undefined instead of throwing when the anyOf/oneOf it reads is not made of constants and no selector field names where the values are. An option with no title of its own and an object or array value is labelled with its JSON instead of [object Object]. A selector property is read by own key, so an option that doesn't declare one named like an Object.prototype member, such as toString, is read by its constant rather than through the inherited function (#5561)
  • orderProperties(): Matches property names by own key. A property named __proto__, declared or held under additionalProperties, is placed at a ui:order wildcard, where it was left out, and a ui:order without a wildcard that doesn't list it now throws, where the property was silently hidden. ObjectField renders that throw as its InvalidObjectField error in place of the object, so add __proto__ or * to such an order. A name like toString that the order lists but the properties don't is dropped rather than returned (#5561)
  • relaxOptionsForScoring(): Removed. omitExtraData() relaxes the whole scope of an option itself, from where the option sits in the root. To score options with their additionalProperties: false relaxed yourself, hand getClosestMatchingOption() or getFirstMatchingOption() copies of them with additionalProperties: true.
  • retrieveSchema(): The stub it builds for a property described only by an additionalProperties with no type now keeps the rest of that subschema instead of discarding it, so a property constrained by an enum, a format, a length or a range renders as a field that honors it rather than as free text. Subschema keywords such as allOf, and a default of another type than the data, are left out, as are the identifiers ($id, $anchor, ...) and containers ($defs, definitions) of a stub whose type the subschema only implies or the data supplies: such a stub is the form's own reading of what one property holds, and copying an $id onto every sibling property would route them all to a field registered under it. This is not gated behind useFallbackUiForUnsupportedType, so such a property renders as the field its constraint calls for in every form, and a value of another type than that constraint allows is as invisible in it as in any field for a schema that names a type. The stub still takes the type of the data the property holds, and is now marked with the new GUESSED_TYPE_FLAG symbol to record that the type was guessed rather than declared by the schema when the subschema asserts nothing about the value. A key whose matching patternProperties subschema names no type is stubbed that way too, where it used to be stubbed with that subschema itself and render as the unsupported field no type can draw. A key that matches none of an object's patternProperties patterns is stubbed from its data the same way, since a schema naming no additionalProperties allows that key as much as additionalProperties: true does; only a schema that forbids such a key still stubs it as { type: 'null' }: an additionalProperties: false, an unevaluatedProperties: false where no additionalProperties evaluates the key, or a matching pattern of false, which rejects every value the key could hold. Which schema describes a key is getAdditionalPropertySchema()'s answer and the type it says the property holds is getAdditionalPropertyType()'s, both of which ObjectField reads to seed a new property, so the field a key renders with and the value it is seeded with cannot disagree. That subschema is resolved before the stub is built, through one ladder for every keyword that can describe a key: an additionalProperties carrying a $ref used to be resolved from that $ref alone, discarding the keywords beside it, where those keywords are now layered over the referenced ones as they are wherever else a $ref appears, and an allOf naming a type now names the stub's type instead of leaving it to the data. A subschema offering an anyOf/oneOf of options that disagree about the type is stubbed without a type of its own, where it used to be stubbed as an object, which rendered an empty object fieldset beside the option selector and seeded the {} no primitive option accepts; options that agree on a type, object included, still name it (#5330). When expanding every branch, resolveAnyOrOneOfSchemas() keeps the keyword it didn't read on each option it returns, so the schema parser also covers the oneOf of a schema whose anyOf it resolved first, which a precompiled validator otherwise threw for at runtime
  • sanitizeDataForNewSchema(): A property is no longer treated as having changed schema when its type differs but both the old and the new type carry GUESSED_TYPE_FLAG, since those types describe the data rather than anything either schema requires. Without this, switching the type of such a property through the fallback UI discarded the value as data belonging to an old schema. A new schema that declares a type of its own is still the type the data has to satisfy. It also compares a default or const with the form data by value rather than by reference, so a readOnly object or array holding its default keeps it, and an untouched object default is swapped for the new schema's. An array item picked from object constants is kept when the new items schema still offers it, rather than sanitized property by property and dropped. An anyOf/oneOf with an option that is neither a const nor an enum no longer counts as a list of allowed values, so a value only that option accepts is kept rather than replaced with one of the constants. An option's enum counts every one of its values as allowed, where one with more than one value used to add none, so a value it allows is kept and one no option allows is cleared. Array items are no longer filtered against the items enum on every pass: with the previous form data passed (the way Form calls it), the filter is skipped when the old and new items schemas, each resolved against its own data, agree - so editing an unrelated field no longer silently drops values the schema offered when they were entered (#5451). Without previous data the filter always runs, matching v6. The filter always runs for a select over object or array constants and for a property the new schema introduces, and a scalar property outside the new enum follows the same agreement rule when previous data is available. Whether a schema or a property is an array is read through getSchemaType(), and two type lists are compared by what they list rather than as objects, so an array whose type list also allows null, such as ['array', 'null'], keeps and sanitizes its items as a plain type: 'array' does, and a property whose type list two options each declare keeps its value across a switch. A tuple, an array declaring no items or the same boolean items, that both schemas describe alike is kept up to the new schema's maxItems rather than cleared, an array the new schema's const rejects is cleared so that the const can take its place, and a property or an array's items whose type list names object, such as ['object', 'null'], is sanitized property by property when it holds an object, as a type: 'object' one is, keeping a null it allows (#5359). A value it sets, clears or sanitizes under a property named __proto__ is written as an own key, where it was lost (#5561)
  • schemaHasNestedConditional(): Removed. Form no longer compares root-level retrieved schemas to decide whether to sanitize: it compares the field trees before and after a change, which reach a dependencies or if at any depth (see Sanitize is a diff of the field trees). Nothing replaces it
  • setByPath(): A __proto__ segment, in any position, is written as an own key through setOwn(), where the path used to be refused and the write dropped. The write itself never reaches the prototype setter, but code of your own that builds the path from user input, such as a key the user typed or a dotted path from a URL, and then copies the result with Object.assign(), a target[key] = source[key] loop or a hand-written recursive merge does reach it, changing the copy's prototype or, through a recursive merge, Object.prototype itself. Copy such a result with spread or setOwn(), or leave __proto__ out of the path. Under additionalProperties: false, AJV doesn't count a declared __proto__ property among the declared ones, so a value written to it fails validation as an additional property (#5561)
  • shouldRenderOptionalField(): Takes the field's fieldPath as a required fifth argument, and its fourth, uiSchema, is no longer optional: shouldRenderOptionalField(registry, schema, required, uiSchema, fieldPath). A field of your own passes the fieldPath from its props; a JavaScript caller that still passes four arguments gets no error, and its root field renders behind an Add button, since a missing fieldPath is not the root's. It returns false for the root field, and no longer mistakes a nested field for it: the option a root anyOf/oneOf selects no longer gets the controls, while a nested object or array that a recursive $ref: '#' describes now does
  • toErrorSchema() and ErrorSchemaBuilder: Write through setByPath(), so an error at a path through a __proto__ key is filed under that key as an own key, where it was dropped. An error a transformErrors points at .__proto__ reaches that field, since the validators build their errorSchema with toErrorSchema() after it runs (#5561)